Merge remote-tracking branches 'origin/sub_laptop_2' and 'origin/sub_laptop_3' into main_laptop_1

This commit is contained in:
2026-10-28 07:04:51 +09:00
16 changed files with 760 additions and 120 deletions
+19 -1
View File
@@ -1,12 +1,14 @@
/* =============================================================================
* D03_PlanAnalysis_Api.ts
* D03 분석 화면 서버 요청(PLAN 563-1) — 지형 받기 범위 · 받기 · 진행 ·
* 사업 전체 지형면 한 벌(`models_plan/` · 격자) · 그은 선 정본(562-2 GET · PUT network · 응답에 D02 시점 · 종점).
* 사업 전체 지형면 한 벌(`models_plan/` · 격자) · 그은 선 정본(562-2 GET · PUT network · 응답에 D02 시점 · 종점) ·
* 지도 겹(569 — 지적도 · 도엽 겹 GeoJSON 경위도 · 급경사 그림).
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { fetchCachedBytes } from "../A00_Common/b_asset_cache";
import { listSurfaceModels } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import type { GeoJsonCollection } from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import { type DtmGrid, parseDtmGrid } from "../common_util/common_util_surface_sampler";
import type { PlanLine, PlanPiece, Vertex } from "./logic/D03_PlanAnalysis_Plan";
@@ -132,3 +134,19 @@ export async function fetchPlanSurface(projectId: string): Promise<PlanSurface |
`?smooth=true&sig=${encodeURIComponent(stamp)}`;
return { modelId: model.id, grid: parseDtmGrid(await fetchCachedBytes(projectId, url)) };
}
/** 지도 겹 그림(569-1) — 작업 좌표계 m 상자 · data URL. */
export interface PlanRaster {
box_m: [number, number, number, number];
image: string;
}
export const fetchPlanCadastral = (projectId: string): Promise<GeoJsonCollection> =>
request(`${base(projectId)}/layers/cadastral`);
/** 도엽 겹(`도로중심선` 등) — 없으면 서버가 받은 도엽에서 풀어 냄. */
export const fetchPlanSheetLayer = (projectId: string, key: string): Promise<GeoJsonCollection> =>
request(`${base(projectId)}/layers/sheet?key=${encodeURIComponent(key)}`);
export const fetchPlanRaster = (projectId: string, kind: "slope"): Promise<PlanRaster> =>
request(`${base(projectId)}/layers/raster?kind=${kind}`);
+13 -12
View File
@@ -360,18 +360,19 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
else context.moveTo(sx, sy);
});
context.stroke();
drawStationTicks(
context,
line.vertices.map(([x, y]) => ({ x, y })),
{
intervalM: PLAN_STATION_INTERVAL_M,
pxPerMeter: map.pxPerMeter,
toScreen: (x, y) => map.toScreen([x, y]),
uprightRad: map.uprightRad,
chainages: stationChainages(lineLength(line.vertices), PLAN_STATION_INTERVAL_M),
},
);
segmentLabels(context, map, line.vertices);
if (map.stations)
drawStationTicks(
context,
line.vertices.map(([x, y]) => ({ x, y })),
{
intervalM: PLAN_STATION_INTERVAL_M,
pxPerMeter: map.pxPerMeter,
toScreen: (x, y) => map.toScreen([x, y]),
uprightRad: map.uprightRad,
chainages: stationChainages(lineLength(line.vertices), PLAN_STATION_INTERVAL_M),
},
);
if (map.slopes) segmentLabels(context, map, line.vertices);
});
// 꺾임점
context.fillStyle = "#ffffff";
@@ -0,0 +1,346 @@
/* =============================================================================
* D03_PlanAnalysis_UI_Layers.ts
* D03 평면 바탕 겹 켜고 끔(PLAN 569-2 · 요구 395-2) — 지도 왼쪽 위 아이콘 한 줄(도구줄과 따로).
*
* 겹 = 위성 · 도엽 등고선(계곡선 25 m 굵게 · 수치) · 세류선(가는 파랑) · 하천(굵은 파랑) · 지적도 ·
* 기존 도로(도엽 도로중심선) · 급경사 색(15° · 35° · 40° · 범례 작은 칸).
* 처음 켬 = 위성 · 등고선 · 세류 · 고른 값 = 화면 취향(`aislo:pref:d03.layers`) 기억.
* 받기 = 켤 때만(한 번 받으면 다시 안 받음) · 지형을 안 받았으면 단추를 막음.
* 그리기 차례(아래 → 위) = 위성(`scene.image`) → 급경사 → 지적 → 등고선 → 도로 → 세류 → 그은 선.
* ========================================================================== */
import { createIconButton } from "@ui/ui_template_button";
import { showToast } from "@ui/ui_template_elements";
import { createLegend } from "@ui/ui_template_flow_legend";
import type { IconName } from "@ui/ui_template_icons";
import { themeColor } from "@ui/ui_template_palette";
import { createToolbar } from "@ui/ui_template_toolbar";
import { fetchCachedSheetLayer } from "../A00_Common/b_asset_cache";
import { readByKey, writeByKey } from "../A00_Common/b_page_state";
import {
fetchVWorldMeta,
getVWorldMapUrl,
type VWorldMeta,
} from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import {
createNormalizer,
drawPreparedLabels,
drawPreparedLayer,
pickLevelStep,
type GeoJsonCollection,
type PreparedLayer,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import { prepareLayer } from "../B04_PreProcess/logic/B04_PreProcess_UI_MapRender_Prepare";
import {
fetchPlanCadastral,
fetchPlanRaster,
fetchPlanSheetLayer,
type PlanRaster,
} from "./D03_PlanAnalysis_Api";
import type { PlanMap, PlanMapLayer, ScreenMap } from "./D03_PlanAnalysis_UI_Map";
import type { Vertex } from "./logic/D03_PlanAnalysis_Plan";
export type PlanLayerKey = "satellite" | "contour" | "stream" | "cadastral" | "road" | "slope";
interface LayerDef {
key: PlanLayerKey;
icon: IconName;
title: string;
on: boolean;
}
const LAYERS: readonly LayerDef[] = [
{ key: "satellite", icon: "satellite", title: "위성사진", on: true },
{ key: "contour", icon: "mountain", title: "도엽 등고선(굵은 선 = 계곡선 25 m)", on: true },
{ key: "stream", icon: "droplet", title: "세류선(가는 파랑) · 하천(굵은 파랑)", on: true },
{ key: "cadastral", icon: "borderAll", title: "지적도(참고용 · 법적 효력 없음)", on: false },
{ key: "road", icon: "road", title: "기존 도로(도엽 도로중심선)", on: false },
{ key: "slope", icon: "angle", title: "급경사 색(15° · 35° · 40° 이상)", on: false },
];
const PREF_KEY = "aislo:pref:d03.layers";
/** 등고선을 받아 올 그은 선 둘레(m). */
const CONTOUR_PAD_M = 600;
const SLOPE_STEPS = [
{ label: "15°", token: "--draw-slope-15" },
{ label: "35°", token: "--draw-slope-35" },
{ label: "40°", token: "--draw-slope-40" },
];
interface Prepared {
contour: PreparedLayer;
/** 세류 · 하천(소하천 · 지방하천 · 연결선). */
stream: [PreparedLayer, PreparedLayer];
cadastral: PreparedLayer;
road: PreparedLayer;
slope: { raster: PlanRaster; image: HTMLImageElement };
}
export interface PlanLayers {
bar: HTMLElement;
legend: HTMLElement;
/** 바탕 겹(아래 → 위 · 위성 뺌) — 그은 선 겹 아래에 둠. */
layers: PlanMapLayer[];
/** 지형(위성 틀)을 다시 읽음 — 처음 · 지형 받은 뒤. */
load: () => Promise<void>;
/** 검증 창구 — 켬끔 · 받은 겹. */
state: () => { on: Record<PlanLayerKey, boolean>; loaded: PlanLayerKey[] };
}
function readPref(): Record<PlanLayerKey, boolean> {
const on = Object.fromEntries(LAYERS.map((def) => [def.key, def.on])) as Record<
PlanLayerKey,
boolean
>;
try {
const saved = JSON.parse(readByKey(PREF_KEY) ?? "{}") as Partial<Record<PlanLayerKey, unknown>>;
for (const def of LAYERS)
if (typeof saved[def.key] === "boolean") on[def.key] = saved[def.key] as boolean;
} catch {
// 깨진 값 = 처음 값
}
return on;
}
const loadImage = (src: string): Promise<HTMLImageElement> =>
new Promise((done, fail) => {
const image = new Image();
image.onload = () => done(image);
image.onerror = () => fail(new Error("그림을 읽지 못했습니다"));
image.src = src;
});
export function createPlanLayers(opts: {
projectId: string;
map: PlanMap;
/** 등고선 받을 범위를 정할 점(시점 · 종점 · 그은 선). */
points: () => Vertex[];
}): PlanLayers {
const { projectId, map } = opts;
const on = readPref();
const data: Partial<Prepared> = {};
const pending = new Map<PlanLayerKey, Promise<void>>();
let meta: VWorldMeta | null = null;
let satellite: HTMLImageElement | null = null;
const buttons = new Map<PlanLayerKey, HTMLButtonElement>();
for (const def of LAYERS) {
const button = createIconButton({
icon: def.icon,
title: def.title,
pressed: on[def.key],
onClick: () => toggle(def.key),
});
button.dataset.layer = def.key;
buttons.set(def.key, button);
}
const bar = createToolbar({
glass: true,
size: 32,
label: "지도 겹",
items: [...buttons.values()],
});
bar.classList.add("d03-plan__layers");
const legend = document.createElement("div");
legend.className = "d03-plan__legend";
legend.title = "급경사 색 — 그 값 이상";
legend.append(
createLegend(SLOPE_STEPS.map((step) => ({ label: step.label, color: `var(${step.token})` }))),
);
function sync(): void {
for (const def of LAYERS) {
const button = buttons.get(def.key)!;
button.setAttribute("aria-pressed", String(on[def.key]));
button.disabled = !meta;
const title = meta ? def.title : `${def.title} — 지형 받기 뒤 보임`;
button.title = title;
button.setAttribute("aria-label", title);
}
legend.hidden = !meta || !on.slope || !data.slope;
}
function toggle(key: PlanLayerKey): void {
on[key] = !on[key];
writeByKey(PREF_KEY, JSON.stringify(on));
void ensure(key);
showSatellite();
sync();
map.redraw();
}
function showSatellite(): void {
map.update({ image: on.satellite ? satellite : null });
}
/** 켜진 겹의 자료를 받음(한 번). */
function ensure(key: PlanLayerKey): Promise<void> {
if (!meta || !on[key] || (key === "satellite" ? satellite : data[key as keyof Prepared]))
return Promise.resolve();
const running = pending.get(key);
if (running) return running;
const at = meta;
const job = fetchLayer(key, at)
.then(() => {
if (meta !== at) return;
showSatellite();
sync();
map.redraw();
})
.catch((error: unknown) => {
const text = error instanceof Error ? error.message : String(error);
showToast(`${LAYERS.find((def) => def.key === key)!.title} 받지 못함 — ${text}`, "error");
})
.finally(() => pending.delete(key));
pending.set(key, job);
return job;
}
async function fetchLayer(key: PlanLayerKey, at: VWorldMeta): Promise<void> {
const normalizer = createNormalizer(at);
const prepare = (collection: GeoJsonCollection, labels?: string[]): PreparedLayer =>
prepareLayer(collection, normalizer, labels);
if (key === "satellite") {
// 범위가 넓어지면 주소가 바뀌어 옛 그림을 안 씀
const v = `${at.x_min}_${at.x_max}_${at.y_min}_${at.y_max}`;
satellite = await loadImage(`${getVWorldMapUrl(projectId, "satellite")}&v=${v}`);
} else if (key === "contour") {
const points = opts.points();
const xs = points.map(([x]) => x);
const ys = points.map(([, y]) => y);
const bbox = [
Math.min(...xs) - CONTOUR_PAD_M,
Math.min(...ys) - CONTOUR_PAD_M,
Math.max(...xs) + CONTOUR_PAD_M,
Math.max(...ys) + CONTOUR_PAD_M,
].map((v) => v.toFixed(0));
const collection = await fetchCachedSheetLayer<GeoJsonCollection>(
projectId,
"도엽_등고선",
"route",
bbox.join(","),
);
data.contour = prepare(collection, ["등고수치"]);
} else if (key === "stream") {
const collection = await fetchCachedSheetLayer<GeoJsonCollection>(
projectId,
"도엽_하천중심선",
);
const pick = (small: boolean): GeoJsonCollection => ({
...collection,
features: (collection.features ?? []).filter(
(feature) => (feature.properties?.["구분"] === "세류") === small,
),
});
data.stream = [prepare(pick(true)), prepare(pick(false))];
} else if (key === "cadastral") {
data.cadastral = prepare(await fetchPlanCadastral(projectId));
} else if (key === "road") {
data.road = prepare(await fetchPlanSheetLayer(projectId, "도로중심선"));
} else {
const raster = await fetchPlanRaster(projectId, "slope");
data.slope = { raster, image: await loadImage(raster.image) };
}
}
const stroke = (
context: CanvasRenderingContext2D,
map: ScreenMap,
layer: PreparedLayer,
color: string,
width: number,
everyM?: number,
): void => {
context.strokeStyle = color;
context.lineWidth = width;
drawPreparedLayer(context, layer, map.view, "dot", everyM);
};
const layers: PlanMapLayer[] = [
{
draw: (context, map) => {
const slope = on.slope && data.slope;
if (!slope) return;
const [x0, y0, x1, y1] = slope.raster.box_m;
const [left, top] = map.toScreen([x0, y1]);
const [right, bottom] = map.toScreen([x1, y0]);
context.globalAlpha = 0.55;
context.drawImage(slope.image, left, top, right - left, bottom - top);
},
},
{
draw: (context, map) => {
if (!on.cadastral || !data.cadastral) return;
context.globalAlpha = 0.8;
stroke(context, map, data.cadastral, themeColor("--draw-map-parcel", "#facc15"), 0.8);
},
},
{
draw: (context, map) => {
const contour = on.contour && data.contour;
if (!contour) return;
const color = themeColor("--draw-map-contour", "#ffd68c");
context.globalAlpha = 0.7;
stroke(context, map, contour, color, 0.8, pickLevelStep(contour, map.view, 5));
stroke(context, map, contour, color, 1.6, 25); // 계곡선
context.globalAlpha = 1;
context.font = "10px system-ui, sans-serif";
context.textAlign = "center";
context.textBaseline = "middle";
drawPreparedLabels(
context,
contour,
map.view,
color,
25,
map.uprightRad,
"rgba(0, 0, 0, 0.6)",
);
},
},
{
draw: (context, map) => {
if (!on.road || !data.road) return;
stroke(context, map, data.road, "rgba(0, 0, 0, 0.55)", 3.2);
stroke(context, map, data.road, themeColor("--draw-map-road", "#f8fafc"), 1.6);
},
},
{
draw: (context, map) => {
if (!on.stream || !data.stream) return;
const color = themeColor("--draw-map-stream", "#38bdf8");
stroke(context, map, data.stream[0], color, 1);
stroke(context, map, data.stream[1], color, 2.4);
},
},
];
async function load(): Promise<void> {
const next = await fetchVWorldMeta(projectId, "satellite").catch(() => null);
const changed =
JSON.stringify(next && [next.x_min, next.x_max, next.y_min, next.y_max]) !==
JSON.stringify(meta && [meta.x_min, meta.x_max, meta.y_min, meta.y_max]);
meta = next;
if (changed) {
satellite = null;
for (const key of Object.keys(data)) delete data[key as keyof Prepared];
}
map.update({ meta });
showSatellite();
sync();
await Promise.all(LAYERS.map((def) => ensure(def.key)));
}
return {
bar,
legend,
layers,
load,
state: () => ({
on: { ...on },
loaded: [
...(satellite ? (["satellite"] as PlanLayerKey[]) : []),
...(Object.keys(data) as PlanLayerKey[]),
],
}),
};
}
+45 -61
View File
@@ -1,29 +1,24 @@
/* =============================================================================
* D03_PlanAnalysis_UI_Map.ts
* D03 메인 평면(PLAN 530-3 · 562-3) — 위성 + 도엽 등고선 바탕 · 위에 겹(그리기 · 거리재기) · 도구줄 여섯
* (되돌리기 · 다시하기 · 3D · 좌회전 · 우회전 · 거리재기).
* D03 메인 평면(PLAN 530-3 · 562-3) — 위성 바탕 · 위에 겹(바탕 겹 569-2 · 그리기 · 거리재기) · 도구줄 아홉
* (공용 `createMapTools` 568-3 — [?] · 되돌리기 · 다시하기 · 3D · 좌회전 · 우회전 · 측점 · 기울기 · 거리재기).
*
* 셈 · 그리기는 B 부품 그대로(import 만) — 좌표 `createMetricProjector` · `screenToMetric` · 맞춤
* `computeRouteView` · 등고선 `drawPreparedLayer` · 휠 확대 · 가운데 단추 옮기기 `bindRouteEditNavigation` ·
* `computeRouteView` · 휠 확대 · 가운데 단추 옮기기 `bindRouteEditNavigation` ·
* 회전 `createMapRotation`(B05 그대로 · 단추 `[data-act="rotate-ccw|cw"]` · Ctrl+휠).
* 왼쪽 단추 = 겹 몫(점 찍기 · 마크 · 재기) — 왼쪽 끌기 옮기기 없음(설정 창 규칙 = 가운데 단추 옮김 · 휠 방향).
* 겹은 돌리기 전 그림 좌표로 그리고 받음 — 누른 자리는 `unrotate` 로 되돌려 넘김.
* ========================================================================== */
import { createIconButton } from "@ui/ui_template_button";
import { createMapTools, mapToolButton } from "@ui/ui_template_map_tools";
import { themeColor } from "@ui/ui_template_palette";
import { createToolbar } from "@ui/ui_template_toolbar";
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import {
computeMapRect,
computeRouteView,
createMetricProjector,
drawPreparedLabels,
drawPreparedLayer,
normalizedToScreen,
pickLevelStep,
routeLineColor,
type PreparedLayer,
type ViewState,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import { bindRouteEditNavigation } from "../B05_Profile/B05_Profile_UI_RouteEdit_Input";
@@ -49,7 +44,6 @@ const FAKE_MARGIN_M = 600;
export interface PlanMapScene {
meta: VWorldMeta | null;
image: HTMLImageElement | null;
contours: PreparedLayer | null;
/** 처음 맞출 점(시점 · 종점 · 그은 선). */
fit: Vertex[];
}
@@ -59,6 +53,11 @@ export interface ScreenMap {
toScreen: (point: Vertex) => [number, number];
toMetric: (px: number, py: number) => Vertex;
pxPerMeter: number;
/** B04 그리기 틀(`drawPreparedLayer` 등이 받음). */
view: ViewState;
/** 측점 눈금 · 구간 길이 · 기울기 글 켬(도구줄 측점 · 기울기). */
stations: boolean;
slopes: boolean;
/** 글자를 세울 각(라디안 · 안 돌렸으면 0). */
uprightRad: number;
/** (x, y) 둘레로 글자를 눈높이로 세워 그림(돌린 지도). */
@@ -132,40 +131,48 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
const root = document.createElement("div");
root.className = "d03-plan__map";
const canvas = document.createElement("canvas");
const tool = (
act: string,
icon: Parameters<typeof createIconButton>[0]["icon"],
title: string,
onClick?: () => void,
): HTMLButtonElement => {
const button = createIconButton({ icon, title, onClick });
button.dataset.act = act;
let marks = { stations: true, slopes: true };
const markButton = (act: "stations" | "slopes", title: string): HTMLButtonElement => {
const button = mapToolButton(act, {
title,
pressed: true,
onClick: () => {
marks = { ...marks, [act]: !marks[act] };
button.setAttribute("aria-pressed", String(marks[act]));
draw();
},
});
return button;
};
const undoButton = tool("undo", "undo", "되돌리기(Ctrl+Z)", events.onUndo);
const redoButton = tool("redo", "redo", "다시하기(Ctrl+Y)", events.onRedo);
const viewButton = tool("view3d", "cube", "3D 보기", events.on3d);
const measureButton = tool("measure", "ruler", "거리재기", events.onMeasure);
measureButton.setAttribute("aria-pressed", "false");
const toolbar = createToolbar({
glass: true,
size: 32,
const undoButton = mapToolButton("undo", { onClick: events.onUndo });
const redoButton = mapToolButton("redo", { onClick: events.onRedo });
const viewButton = mapToolButton("view3d", { onClick: events.on3d });
const measureButton = mapToolButton("measure", { onClick: events.onMeasure });
const tools = createMapTools({
label: "평면 도구",
items: [
undoButton,
redoButton,
"divider",
viewButton,
tool("rotate-ccw", "rotateCcw", "왼쪽으로 돌림(15°)"),
tool("rotate-cw", "rotateCw", "오른쪽으로 돌림(15°)"),
"divider",
measureButton,
hint: {
lines: [
"누름 = 점 찍기",
"Enter = 긋기 끝",
"Esc = 긋기 취소",
"Backspace = 마지막 점 빼기",
"오른쪽 누름 = 마크 지움",
"가운데 끌기 = 옮기기",
"휠 = 확대 · 축소",
"Ctrl+휠 = 돌리기",
],
},
groups: [
[undoButton, redoButton],
[viewButton, "rotate-ccw", "rotate-cw"],
[markButton("stations", "측점"), markButton("slopes", "구간 길이 · 기울기"), measureButton],
],
});
const toolbar = tools.root;
toolbar.classList.add("d03-plan__tools");
root.append(canvas, toolbar);
const context = canvas.getContext("2d")!;
let scene: PlanMapScene = { meta: null, image: null, contours: null, fit: [] };
let scene: PlanMapScene = { meta: null, image: null, fit: [] };
let layers: PlanMapLayer[] = [];
let meta: VWorldMeta | null = null;
let fitted = false;
@@ -189,6 +196,8 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
toScreen: (point) => projector.toScreen(point[0], point[1]),
toMetric: (px, py) => screenToMetric(meta!, view, px, py),
pxPerMeter: projector.pxPerMeter,
view,
...marks,
uprightRad: rotation.uprightRad(),
upright: (ctx, x, y, paint) => {
const angle = rotation.uprightRad();
@@ -234,31 +243,6 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
const [x1, y1] = normalizedToScreen(view, 1, 1);
context.drawImage(scene.image, x0, y0, x1 - x0, y1 - y0);
}
if (scene.contours) {
context.save();
context.strokeStyle = "rgba(255, 214, 140, 0.7)";
context.lineWidth = 0.8;
drawPreparedLayer(
context,
scene.contours,
view,
"dot",
pickLevelStep(scene.contours, view, 5),
);
context.font = "10px system-ui, sans-serif";
context.textAlign = "center";
context.textBaseline = "middle";
drawPreparedLabels(
context,
scene.contours,
view,
"#fde68a",
25,
rotation.uprightRad(),
"rgba(0, 0, 0, 0.6)",
);
context.restore();
}
const map = screenMap();
for (const layer of layers) {
context.save();
+13 -41
View File
@@ -2,7 +2,7 @@
* D03_PlanAnalysis_UI_Page.ts
* D 예정노선 2단계 노선분석(요구서 389 3장 6~13 · PLAN 562-3 · 563-1) — B05~B09 틀(`mountGroupStep`).
*
* 메인 = 평면(위성 + 도엽 등고선 + 그은 선 · 측점 · 구역 마크) + 도구 여섯.
* 메인 = 평면(바탕 겹 켜고 끔 — 위성 · 등고선 · 세류 · 지적 · 도로 · 급경사 + 그은 선 · 측점 · 구역 마크) + 도구 여섯.
* 좌 = 지형(범위를 카카오 지도에 먼저 보임 → [이 범위 받기] · [범위 밖 더 받기]는 선이 받은 범위 밖일 때만) ·
* 그리기(본선 · 갈래 · 구역 마크 · 제약 칸) · 구역 · 연도(조각마다 연도) · [저장] [초기화].
*
@@ -26,16 +26,9 @@ import {
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { fetchCachedSheetLayer, DRAINAGE_SHEET_LAYERS } from "../A00_Common/b_asset_cache";
import { clearState, readState, writeState } from "../A00_Common/b_page_state";
import { mountGroupStep } from "../A00_Common/group_workflow";
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
import { fetchVWorldMeta, getVWorldMapUrl } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import {
createNormalizer,
type GeoJsonCollection,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import { prepareLayer } from "../B04_PreProcess/logic/B04_PreProcess_UI_MapRender_Prepare";
import { heightAt as gridHeightAt } from "../common_util/common_util_surface_sampler";
import { stationLabel } from "../common_util/common_util_station";
import {
@@ -52,6 +45,7 @@ import {
} from "./D03_PlanAnalysis_Api";
import { openPlan3d } from "./D03_PlanAnalysis_UI_3D";
import { createPlanDraw } from "./D03_PlanAnalysis_UI_Draw";
import { createPlanLayers } from "./D03_PlanAnalysis_UI_Layers";
import { createPlanMap, PLAN_STATION_INTERVAL_M } from "./D03_PlanAnalysis_UI_Map";
import { createPlanMeasure } from "./D03_PlanAnalysis_UI_Measure";
import { lineLength, stationChainages } from "./logic/D03_PlanAnalysis_Draw";
@@ -67,8 +61,6 @@ import {
} from "./logic/D03_PlanAnalysis_Plan";
const POLL_MS = 1000;
/** 등고선을 받아 올 그은 선 둘레(m). */
const CONTOUR_PAD_M = 600;
/** 되돌리기 쌓는 수. */
const UNDO_MAX = 100;
/** 조각 연도 고를 수 — 사업연도 시작부터. */
@@ -122,6 +114,12 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
on3d: open3d,
onMeasure: () => setMeasure(!measuring),
});
const backdrop = createPlanLayers({
projectId,
map,
points: () => [start, end, ...allVertices()],
});
map.root.append(backdrop.bar, backdrop.legend);
const main = document.createElement("div");
main.className = "d03-plan";
main.append(map.root);
@@ -210,7 +208,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
measuring = on;
measure.clear();
if (on) draw.setMode("idle");
map.setLayers(measuring ? [draw.layer, measure.layer] : [draw.layer]);
map.setLayers([...backdrop.layers, draw.layer, ...(measuring ? [measure.layer] : [])]);
refresh();
}
@@ -376,7 +374,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
const job = await watch();
if (!job || !root.isConnected) return;
failed = job.error;
await Promise.all([loadSurface(), loadBackground()]);
await Promise.all([loadSurface(), backdrop.load()]);
refresh();
}
@@ -417,33 +415,6 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
}
}
async function loadBackground(): Promise<void> {
const meta = await fetchVWorldMeta(projectId, "satellite").catch(() => null);
if (!meta) return map.update({ meta: null, image: null, contours: null });
const image = new Image();
image.onload = () => map.redraw();
// 범위가 넓어지면 주소가 바뀌어 옛 그림을 안 씀
image.src = `${getVWorldMapUrl(projectId, "satellite")}&v=${meta.x_min}_${meta.x_max}_${meta.y_min}_${meta.y_max}`;
const points = [start, end, ...allVertices()];
const xs = points.map(([x]) => x);
const ys = points.map(([, y]) => y);
const bbox = [
Math.min(...xs) - CONTOUR_PAD_M,
Math.min(...ys) - CONTOUR_PAD_M,
Math.max(...xs) + CONTOUR_PAD_M,
Math.max(...ys) + CONTOUR_PAD_M,
].map((v) => v.toFixed(0));
const contours = await fetchCachedSheetLayer<GeoJsonCollection>(
projectId,
DRAINAGE_SHEET_LAYERS[0],
"route",
bbox.join(","),
)
.then((collection) => prepareLayer(collection, createNormalizer(meta), ["등고수치"]))
.catch(() => null);
map.update({ meta, image, contours });
}
/** 서버 응답 → 지금 값 · 저장본(빈 선 = 시점만). */
function takeServer(view: PlanNetworkView): void {
if (view.year_start) yearStart = view.year_start;
@@ -536,6 +507,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
dirty,
saveMs: () => lastSaveMs,
heightAt: z,
layers: backdrop.state,
};
const onKey = (event: KeyboardEvent): void => {
if (!root.isConnected) return window.removeEventListener("keydown", onKey);
@@ -557,7 +529,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
}
};
window.addEventListener("keydown", onKey);
map.setLayers([draw.layer]);
map.setLayers([...backdrop.layers, draw.layer]);
try {
editable = !!(await fetchSessionUser())?.is_forest_service;
@@ -578,7 +550,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
refresh();
await loadSurface();
refresh();
await loadBackground();
await backdrop.load();
if ((await fetchPlanProgress(projectId)).terrain.running) await follow();
} catch (error) {
terrainText.textContent = `불러오지 못했습니다 — ${errorText(error)}`;
+18 -1
View File
@@ -29,6 +29,23 @@
position: absolute;
top: var(--spacing-8);
right: var(--spacing-8);
align-items: flex-end;
}
/* 569-2 바탕 겹 단추 — 왼쪽 위 · 급경사 범례 — 왼쪽 아래 */
.d03-plan__layers {
position: absolute;
top: var(--spacing-8);
left: var(--spacing-8);
}
.d03-plan__legend {
position: absolute;
bottom: var(--spacing-8);
left: var(--spacing-8);
padding: var(--spacing-4) var(--spacing-8);
border-radius: var(--radius-lg);
background: var(--color-surface);
}
.d03-plan__hint {
@@ -95,7 +112,7 @@
.d03-plan__3d-body > .b05-route__viewport {
flex: 1 1 auto;
min-height: 0;
border-radius: var(--radius-md);
border-radius: var(--radius-lg);
}
.d03-plan__3d-top {
+1 -1
View File
@@ -124,7 +124,7 @@ def test_draw_values_are_literal_hex():
assert draws, mode
for name, value in draws.items():
assert HEX.fullmatch(value), (mode, name, value)
assert len([k for k in blocks["light"] if k.startswith("--draw-")]) == 17
assert len([k for k in blocks["light"] if k.startswith("--draw-")]) == 24
def test_drawing_css_uses_no_screen_tokens():
+1
View File
@@ -25,6 +25,7 @@ PARTS = {
"float-panel": ("ui_template_float_panel", "createFloatPanel", "ui-float-panel", (32,)),
"drawer": ("ui_template_side_panels", "createSidePanels", "ui-side-panels", ()),
"toolbar": ("ui_template_toolbar", "createToolbar", "ui-toolbar", (24, 28, 32)),
"map-tools": ("ui_template_map_tools", "createMapTools", "ui-map-tools", ()),
"steps": ("ui_template_workflow_layout", "createStepBar", "ui-workflow-layout__step", (28, 40)),
"panel-head": ("ui_template_panel_head", "createPanelHead", "ui-panel-head", (32,)),
"list-row": ("ui_template_list_row", "createListRow", "ui-list-row", (28, 32, 36)),
+24 -3
View File
@@ -33,13 +33,34 @@ def test_d03_parts():
css = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css")
draw = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts")
tools = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts")
layers = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts")
assert 'createElement("progress")' not in ts and "createProgressBar()" in ts
# 562-3 — 날 단추 · 입력 · 고르기 없음 · 도구줄 · 제약 칸 · 연도 고름은 부품
for src in (ts, draw, tools):
for src in (ts, draw, tools, layers):
for raw in ('createElement("button")', 'createElement("input")', 'createElement("select")'):
assert raw not in src, raw
assert "createToolbar({" in tools and "createIconButton(" in tools
assert '"rotateCcw"' in tools and '"rotateCw"' in tools and "leftPan" not in tools
# 568-3 — 도구줄 = 공용 지도 도구줄 · 아홉([?] · 되돌리기 · 다시하기 · 3D · 돌리기 둘 · 측점 · 기울기 · 재기)
assert "createMapTools({" in tools and "hint:" in tools and "leftPan" not in tools
assert "createToolbar(" not in tools and "createIconButton(" not in tools
for act in ("undo", "redo", "view3d", "measure", "stations", "slopes"):
assert f'mapToolButton("{act}"' in tools or f'markButton("{act}"' in tools, act
assert '"rotate-ccw", "rotate-cw"' in tools
for b05_only in ("footprint", "station-snap", "contour-minor"):
assert f'"{b05_only}"' not in tools, b05_only
assert "map.stations" in draw and "map.slopes" in draw
# 569-2 — 바탕 겹 단추(왼쪽 위 아이콘 줄) · 처음 켬 셋 · 취향 기억
assert "createToolbar({" in layers and "createIconButton({" in layers
assert '"aislo:pref:d03.layers"' in layers and "writeByKey(PREF_KEY" in layers
keys = re.findall(r'\{ key: "(\w+)", icon: "\w+", title: "[^"]+", on: (true|false) \}', layers)
assert keys == [
("satellite", "true"),
("contour", "true"),
("stream", "true"),
("cadastral", "false"),
("road", "false"),
("slope", "false"),
], keys
assert ".d03-plan__layers" in css and ".d03-plan__legend" in css
assert "createSegment({" in draw and draw.count("createInputField(") == 2
assert "createSelectField(" in ts # 조각 연도
assert 'createElement("select")' not in three and "createSelectField" in three
+81
View File
@@ -0,0 +1,81 @@
"""568-1 지도 도구줄 부품 지킴 시험 — 화면 확인은 부품 모음(`#/b01-account?view=ui-kit`) · ORCA.
· 단추 목록의 아이콘이 모두 아이콘 묶음에 있음 · 새 아이콘 여덟(도구 넷 · 겹 넷)
· 켬끔 단추 = aria-pressed · [?] = data-act="hint-toggle" + aria-expanded · 안내 판 기본 접힘
./venv/Scripts/python.exe -m pytest resources/tester/test_568_1_map_tools.py -q
"""
from __future__ import annotations
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
UI = ROOT / "ui_template"
TS = (UI / "ui_template_map_tools.ts").read_text(encoding="utf-8")
ICONS = (UI / "ui_template_icons.ts").read_text(encoding="utf-8")
CSS = (UI / "ui_template_map_tools.css").read_text(encoding="utf-8")
def _icon_names() -> set[str]:
body = ICONS[ICONS.index("const PATHS") : ICONS.index("} as const")]
return set(re.findall(r"^ (\w+):", body, re.M))
def test_새_아이콘_여덟():
names = _icon_names()
for n in (
"ripple",
"tallymarks",
"percentage",
"magnet",
"satellite",
"droplet",
"borderAll",
"angle",
):
assert n in names, n
def test_단추_목록_아이콘_차례():
table = TS[TS.index("export const MAP_TOOLS") : TS.index("satisfies")]
rows = re.findall(
r'^ "?([\w-]+)"?: \{ icon: "(\w+)", title: "[^"]+"(, toggle: true)? \}', table, re.M
)
acts = [a for a, _, _ in rows]
assert acts == [
"undo",
"redo",
"view3d",
"rotate-ccw",
"rotate-cw",
"contour-minor",
"stations",
"slopes",
"footprint",
"measure",
"station-snap",
]
names = _icon_names()
assert all(icon in names for _, icon, _ in rows)
toggles = {a for a, _, t in rows if t}
assert toggles == {
"contour-minor",
"stations",
"slopes",
"footprint",
"measure",
"station-snap",
}
def test_켬끔_안내_판():
assert "pressed: tool.toggle ? (opts.pressed ?? false) : undefined" in TS
assert "button.dataset.act = act;" in TS
assert 'toggle.dataset.act = "hint-toggle";' in TS
assert 'toggle.setAttribute("aria-expanded", String(open));' in TS
assert "setOpen(opts.hint.open ?? false);" in TS # 기본 접힘
hint = CSS[CSS.index(".ui-map-tools__hint {") :]
assert "display: none;" in hint[: hint.index("}")]
assert "display: grid;" in CSS[CSS.index(".ui-map-tools__hint.is-open {") :]
assert "pointer-events: none;" in CSS # 빈 자리 · 글판은 지도 조작 통과
+15
View File
@@ -123,6 +123,21 @@ const PATHS = {
mail: "M3 7a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v10a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-10|M3 7l9 6l9 -6",
inbox:
"M4 6a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-12|M4 13h3l3 3h4l3 -3h3",
// 568-1 — 지도 도구(1m 등고선 · 측점 · 기울기 · 측점 스냅)
ripple: "M3 7c3 -2 6 -2 9 0s6 2 9 0|M3 17c3 -2 6 -2 9 0s6 2 9 0|M3 12c3 -2 6 -2 9 0s6 2 9 0",
tallymarks: "M6 5l0 14|M10 5l0 14|M14 5l0 14|M18 5l0 14|M3 17l18 -10",
percentage: "M16 17a1 1 0 1 0 2 0a1 1 0 0 0 -2 0|M6 7a1 1 0 1 0 2 0a1 1 0 0 0 -2 0|M6 18l12 -12",
magnet:
"M4 13v-8a2 2 0 0 1 2 -2h1a2 2 0 0 1 2 2v8a2 2 0 0 0 6 0v-8a2 2 0 0 1 2 -2h1a2 2 0 0 1 2 2v8a8 8 0 0 1 -16 0|M4 8l5 0|M15 8l4 0",
// 568-1 — 지도 겹(위성 · 세류선 · 지적도 · 급경사)
satellite:
"M3.707 6.293l2.586 -2.586a1 1 0 0 1 1.414 0l5.586 5.586a1 1 0 0 1 0 1.414l-2.586 2.586a1 1 0 0 1 -1.414 0l-5.586 -5.586a1 1 0 0 1 0 -1.414z|M6 10l-3 3l3 3l3 -3|M10 6l3 -3l3 3l-3 3|M12 12l1.5 1.5|M14.5 17a2.5 2.5 0 0 0 2.5 -2.5|M15 21a6 6 0 0 0 6 -6",
droplet:
"M7.502 19.423c2.602 2.105 6.395 2.105 8.996 0c2.602 -2.105 3.262 -5.708 1.566 -8.546l-4.89 -7.26c-.42 -.625 -1.287 -.803 -1.936 -.397a1.376 1.376 0 0 0 -.41 .397l-4.893 7.26c-1.695 2.838 -1.035 6.441 1.567 8.546z",
borderAll:
"M4 6a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z|M4 12l16 0|M12 4l0 16",
angle:
"M21 19h-18l9 -15|M20.615 15.171h.015|M19.515 11.771h.015|M17.715 8.671h.015|M15.415 5.971h.015",
} as const;
export type IconName = keyof typeof PATHS;
+33
View File
@@ -0,0 +1,33 @@
/* 지도 화면 제어 도구줄(568-1) — 도구줄 + 아래 [?] 안내 판 · 자리는 페이지가 줌 · 빈 자리는 지도 조작 통과 */
.ui-map-tools {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-8);
pointer-events: none;
}
.ui-map-tools > .ui-toolbar {
max-width: 100%;
pointer-events: auto;
}
/* 안내 판 — 기본 접힘 · 2열 · 글판은 클릭 통과 */
.ui-map-tools__hint {
display: none;
grid-template-columns: auto auto;
gap: 2px var(--spacing-16);
max-width: 100%;
padding: var(--spacing-8) var(--spacing-12);
border: 1px solid color-mix(in srgb, var(--color-border) 65%, transparent);
border-radius: var(--radius-cards);
background: color-mix(in srgb, var(--color-surface-raised) 72%, transparent);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
color: var(--color-text-secondary);
font-size: var(--text-caption);
}
.ui-map-tools__hint.is-open {
display: grid;
}
+119
View File
@@ -0,0 +1,119 @@
/* =============================================================================
* ui_template_map_tools.ts
* 지도 화면 제어 도구줄(568-1) — 평면 지도 위 아이콘 단추 한 벌 · B05 평면 · D03 평면이 같이 씀.
* 꼴 = 유리 도구줄(`createToolbar` glass · 32) 한 줄 · 묶음 사이 나눔선 · 단추는 모두 아이콘(마우스 올리면 이름)
* [?] 안내 판 = 도구줄 아래 2열 글판(기본 접힘 · 클릭은 지도로 통과)
* 크기 = 단 32 하나(지도 위 도구)
* 상태 = 켬끔 단추는 `aria-pressed`(눌림 꼴) · 막힘 `disabled` · [?] 는 `aria-expanded`
* 쓰는 법 = createMapTools({ label: "평면 도구", groups: [["undo", "redo"], ["rotate-ccw", "rotate-cw", "measure"]],
* hint: { lines: ["휠 = 확대"] } }) → { root, bar, hint, button(act) }
* 페이지 문구가 다르면 `mapToolButton("measure", { title })` 를 묶음에 그대로 끼움 · 고르기 · 칩도 요소로 끼움
* 누름 동작은 페이지가 붙임(`onClick` 또는 `[data-act]` 로 찾아 붙임) · 자리는 페이지 CSS(root)
* 대신하는 옛 변종 = B05 `b05-route__view-controls` 글자 유리 단추 줄 · `b05-routeedit__hint` 안내 판 · D03 `tool()` 지역 함수
* ========================================================================== */
import { createIconButton } from "./ui_template_button";
import type { IconName } from "./ui_template_icons";
import { createToolbar } from "./ui_template_toolbar";
import "./ui_template_map_tools.css";
/** 단추 목록 — 차례 = 도구줄 차례 · toggle = 켬끔 단추(aria-pressed) */
export const MAP_TOOLS = {
undo: { icon: "undo", title: "되돌리기 (Ctrl+Z)" },
redo: { icon: "redo", title: "다시하기 (Ctrl+Y)" },
view3d: { icon: "cube", title: "3D 보기" },
"rotate-ccw": { icon: "rotateCcw", title: "반시계로 돌리기" },
"rotate-cw": { icon: "rotateCw", title: "시계로 돌리기" },
"contour-minor": { icon: "ripple", title: "1m 등고선", toggle: true },
stations: { icon: "tallymarks", title: "측점", toggle: true },
slopes: { icon: "percentage", title: "기울기", toggle: true },
footprint: { icon: "road", title: "절/성토선", toggle: true },
measure: { icon: "ruler", title: "거리 재기", toggle: true },
"station-snap": { icon: "magnet", title: "측점 스냅", toggle: true },
} satisfies Record<string, { icon: IconName; title: string; toggle?: boolean }>;
export type MapToolAct = keyof typeof MAP_TOOLS;
export interface MapToolButtonOptions {
/** 마우스 글 — 없으면 목록 이름 */
title?: string;
/** 켬끔 단추 처음 값(기본 false) */
pressed?: boolean;
disabled?: boolean;
onClick?: (ev: MouseEvent) => void;
}
/** 아이콘 단추 하나(`data-act` = act) */
export function mapToolButton(act: MapToolAct, opts: MapToolButtonOptions = {}): HTMLButtonElement {
const tool: { icon: IconName; title: string; toggle?: boolean } = MAP_TOOLS[act];
const button = createIconButton({
icon: tool.icon,
title: opts.title ?? tool.title,
size: 32,
pressed: tool.toggle ? (opts.pressed ?? false) : undefined,
disabled: opts.disabled,
onClick: opts.onClick,
});
button.dataset.act = act;
return button;
}
export interface MapToolsOptions {
/** 묶음들 — 사이에 나눔선 · act 이름 또는 페이지가 만든 요소 */
groups: (MapToolAct | HTMLElement)[][];
/** 보조기기 이름 */
label: string;
/** [?] 안내 판(맨 앞 묶음) — 줄마다 한 칸 · 2열 */
hint?: { lines: string[]; open?: boolean; onToggle?: (open: boolean) => void };
}
export interface MapTools {
root: HTMLElement;
bar: HTMLElement;
hint: HTMLElement | null;
button(act: MapToolAct): HTMLButtonElement | null;
}
export function createMapTools(opts: MapToolsOptions): MapTools {
const root = document.createElement("div");
root.className = "ui-map-tools";
const groups = opts.groups.map((group) =>
group.map((item) => (typeof item === "string" ? mapToolButton(item) : item)),
);
let hint: HTMLElement | null = null;
if (opts.hint) {
const { lines, onToggle } = opts.hint;
const toggle = createIconButton({ icon: "help", title: "조작 도움말 펼치기 · 접기", size: 32 });
toggle.dataset.act = "hint-toggle";
const panel = document.createElement("div");
panel.className = "ui-map-tools__hint";
for (const line of lines)
panel.append(Object.assign(document.createElement("span"), { textContent: line }));
const setOpen = (open: boolean): void => {
panel.classList.toggle("is-open", open);
toggle.setAttribute("aria-expanded", String(open));
};
setOpen(opts.hint.open ?? false);
toggle.addEventListener("click", () => {
const open = !panel.classList.contains("is-open");
setOpen(open);
onToggle?.(open);
});
groups.unshift([toggle]);
hint = panel;
}
const bar = createToolbar({
glass: true,
size: 32,
label: opts.label,
items: groups.flatMap((group, i) => (i ? ["divider" as const, ...group] : group)),
});
root.append(bar);
if (hint) root.append(hint);
return {
root,
bar,
hint,
button: (act) => root.querySelector<HTMLButtonElement>(`[data-act="${act}"]`),
};
}
+7
View File
@@ -551,6 +551,13 @@ html.is-bm-route .m01lab-src__body * {
--draw-surface: #f6f7fa;
--draw-surface-raised: #ffffff; /* 그림 바탕 · 3D 바탕 */
--draw-canvas: #ffffff; /* CAD 바탕 */
--draw-map-contour: #ffd68c; /* 위성 위 겹(노선분석) — 도엽 등고선 */
--draw-map-stream: #38bdf8; /* 세류 · 하천 */
--draw-map-road: #f8fafc; /* 기존 도로 */
--draw-map-parcel: #facc15; /* 지적 경계 */
--draw-slope-15: #fde047; /* 급경사 색 15° 이상 */
--draw-slope-35: #f97316; /* 35° 이상 */
--draw-slope-40: #dc2626; /* 40° 이상 */
}
:root[data-theme="dark"] {
+23
View File
@@ -10,6 +10,7 @@ import { createFloatPanel } from "./ui_template_float_panel";
import { createFormGrid, createPageFrame } from "./ui_template_general_layout";
import { createInputField } from "./ui_template_input";
import { createListRow } from "./ui_template_list_row";
import { createMapTools, mapToolButton } from "./ui_template_map_tools";
import { createWorkflowPanelHandle } from "./ui_template_overlay";
import { createPanelHead } from "./ui_template_panel_head";
import { createPublicFrame } from "./ui_template_public_layout";
@@ -218,6 +219,27 @@ function toolbar(): HTMLElement {
return box;
}
function mapTools(): HTMLElement {
const tools = createMapTools({
label: "평면 도구",
hint: { lines: ["휠 = 확대", "가운데 끌기 = 이동", "Ctrl+휠 = 돌리기", "Esc = 그리기 끝"] },
groups: [
["undo", "redo"],
["view3d", "rotate-ccw", "rotate-cw"],
[
"contour-minor",
"stations",
mapToolButton("slopes", { pressed: true }),
"footprint",
"measure",
],
["station-snap"],
],
});
tools.button("contour-minor")!.disabled = true;
return tools.root;
}
/** 열쇠 → 견본(상태별 · null = 그 상태 없음) */
export const FRAME_DEMOS: Record<string, (state: KitState) => HTMLElement | null> = {
"work-frame": (s) => (s === "default" ? workFrame() : null),
@@ -226,6 +248,7 @@ export const FRAME_DEMOS: Record<string, (state: KitState) => HTMLElement | null
"float-panel": (s) => (s === "default" ? floatPanel() : null),
drawer: (s) => (s === "default" ? drawer() : null),
toolbar: (s) => (s === "default" ? toolbar() : null),
"map-tools": (s) => (s === "default" ? mapTools() : null),
steps,
"panel-head": (s) => (s === "default" ? panelHead() : null),
"list-row": listRow,
+2
View File
@@ -363,6 +363,8 @@ export const KIT_PARTS: KitPart[] = [
demo: FRAME_DEMOS["drawer"] },
{ key: "toolbar", name: "도구줄", group: "frame", kinds: "단추 묶음 · 나눔선 · 밀기 · 유리 · 24 · 28 · 32", file: "ui_template_toolbar.ts",
demo: FRAME_DEMOS["toolbar"] },
{ key: "map-tools", name: "지도 도구줄", group: "frame", kinds: "아이콘 단추 한 벌 · 묶음 나눔선 · 켬끔(aria-pressed) · [?] 안내 판 · 페이지가 쓸 단추만", file: "ui_template_map_tools.ts",
demo: FRAME_DEMOS["map-tools"] },
{ key: "steps", name: "단계 줄", group: "frame", kinds: "가로 막대 · compact(28) · 세로 목록(40 · ☰ 서랍)", file: "ui_template_workflow_layout.ts",
demo: FRAME_DEMOS.steps },
{ key: "panel-head", name: "패널 머리", group: "frame", kinds: "제목 · 부제 · 오른쪽 단추 · 밑줄", file: "ui_template_panel_head.ts",