Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1
This commit is contained in:
@@ -410,3 +410,91 @@
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* 기댐 선 — 규칙이 기대는 알약 → 그 규칙이 있는 곳 · 흐린 점선 + 화살 */
|
||||
.m02-dfdg__dep {
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m02-dfdg__depwire {
|
||||
fill: none;
|
||||
stroke: color-mix(in srgb, var(--color-text-muted) 70%, transparent);
|
||||
stroke-width: 1.5;
|
||||
stroke-dasharray: 3 4;
|
||||
}
|
||||
|
||||
.m02-dfdg__arrow {
|
||||
fill: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.m02-dfdg__deptext rect {
|
||||
fill: var(--color-canvas);
|
||||
stroke: none;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.m02-dfdg__deptext text {
|
||||
fill: var(--color-text-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.m02-dfdg__dep.is-strong .m02-dfdg__depwire {
|
||||
stroke: var(--color-accent);
|
||||
stroke-width: 2.5;
|
||||
}
|
||||
|
||||
.m02-dfdg__dep.is-back .m02-dfdg__depwire {
|
||||
stroke: var(--dfdg-back);
|
||||
stroke-width: 2.5;
|
||||
}
|
||||
|
||||
.m02-dfdg__dep.is-fwd .m02-dfdg__depwire {
|
||||
stroke: var(--dfdg-fwd);
|
||||
stroke-width: 2.5;
|
||||
}
|
||||
|
||||
.m02-dfdg__dep.is-dim {
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
/* 쉼 — 조건이 안 맞아 이번엔 안 지나감(문제 아님) */
|
||||
.m02-dfdg__link.is-rest .m02-dfdg__wire {
|
||||
stroke: var(--color-text-muted);
|
||||
stroke-width: 1.5;
|
||||
stroke-dasharray: 2 5;
|
||||
}
|
||||
|
||||
.m02-dfdg__restmark rect {
|
||||
fill: var(--color-surface);
|
||||
stroke: var(--color-text-muted);
|
||||
stroke-dasharray: 2 2;
|
||||
}
|
||||
|
||||
.m02-dfdg__restmark text {
|
||||
fill: var(--color-text-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.m02-dfdg__box.is-rest {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* 빈 판 안내 — 가운데 세 줄 */
|
||||
.m02-dfdg__empty {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
margin: 0;
|
||||
padding: var(--spacing-16) var(--spacing-24);
|
||||
list-style: none;
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-secondary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,10 @@ import {
|
||||
clampZoom,
|
||||
fitView,
|
||||
KIND_COLOR,
|
||||
depInTrace,
|
||||
follow,
|
||||
depLines,
|
||||
isRest,
|
||||
linkLabel,
|
||||
midOf,
|
||||
pillsOf,
|
||||
@@ -76,7 +80,15 @@ export function renderDiagram(
|
||||
states.set(host, st);
|
||||
const values = opts.values ?? {};
|
||||
const labels = opts.labels ?? {};
|
||||
const breaks = opts.breaks ?? [];
|
||||
const allBreaks = opts.breaks ?? [];
|
||||
/** 「!」 는 문제에만 · 쉼(조건이 안 맞아 이번엔 안 지나감)은 회색 점선 선 + 받는 상자 흐리게 */
|
||||
const breaks = allBreaks.filter((k) => !isRest(k));
|
||||
const rests = allBreaks.filter(isRest);
|
||||
const restLinks = new Set(rests.map((k) => k.link).filter(Boolean));
|
||||
const restBoxes = new Set([
|
||||
...rests.filter((k) => !k.link).map((k) => k.box),
|
||||
...lg.links.filter((l) => restLinks.has(l.id)).map((l) => l.to.box),
|
||||
]);
|
||||
const boxName = (id: string): string => lg.boxes.find((b) => b.id === id)?.name ?? "";
|
||||
const redraw = (): void =>
|
||||
renderDiagram(host, lg, on, { ...opts, selected: st.selected, selectedPill: st.pill });
|
||||
@@ -210,7 +222,8 @@ export function renderDiagram(
|
||||
const node = el("div", {
|
||||
className:
|
||||
`m02-dfdg__box is-${b.kind}${st.selected === b.id ? " is-selected" : ""}` +
|
||||
(st.selected === b.id ? "" : markOf((s) => s.some((x) => x.box === b.id))),
|
||||
(st.selected === b.id ? "" : markOf((s) => s.some((x) => x.box === b.id))) +
|
||||
(restBoxes.has(b.id) ? " is-rest" : ""),
|
||||
attrs: {
|
||||
"data-box": b.id,
|
||||
tabindex: "0",
|
||||
@@ -400,7 +413,7 @@ export function renderDiagram(
|
||||
}),
|
||||
],
|
||||
}),
|
||||
lg.boxes.length ? view : el("p", { className: "m02-dfdg__hint", text: tt("Empty") }),
|
||||
view,
|
||||
],
|
||||
});
|
||||
root.addEventListener("keydown", (ev) => {
|
||||
@@ -438,19 +451,27 @@ export function renderDiagram(
|
||||
);
|
||||
view.addEventListener("auxclick", (ev) => ev.button === 1 && ev.preventDefault());
|
||||
view.addEventListener("mousedown", (ev) => ev.button === 1 && ev.preventDefault());
|
||||
if (!lg.boxes.length)
|
||||
view.append(
|
||||
el("ol", {
|
||||
className: "m02-dfdg__empty",
|
||||
children: (["Empty1", "Empty2", "Empty3"] as const).map((k) => el("li", { text: tt(k) })),
|
||||
}),
|
||||
);
|
||||
host.replaceChildren(root);
|
||||
applyView();
|
||||
|
||||
/* 선 — 알약 자리를 재서 세상 좌표로 · 가운데 규칙 이름표 · 끊긴 곳 */
|
||||
/* 선 — 알약 자리를 재서 세상 좌표로 · 가운데 규칙 이름표 · 끊긴 곳 · 쉼 · 기댐 선 */
|
||||
function drawLines(): void {
|
||||
svg.replaceChildren();
|
||||
const at = (end: CfPort, dir: "in" | "out"): { x: number; y: number } | null => {
|
||||
const at = (end: CfPort, dir: "in" | "out", side: "near" | "far" = "near") => {
|
||||
const pill = world.querySelector<HTMLElement>(
|
||||
`.m02-dfdg__pill[data-box="${CSS.escape(end.box)}"][data-name="${CSS.escape(end.name)}"][data-dir="${dir}"]`,
|
||||
);
|
||||
if (!pill) return null;
|
||||
const r = pill.getBoundingClientRect();
|
||||
return toWorld(dir === "out" ? r.right : r.left, r.top + r.height / 2);
|
||||
const right = (dir === "out") === (side === "near");
|
||||
return toWorld(right ? r.right : r.left, r.top + r.height / 2);
|
||||
};
|
||||
const add = <K extends keyof SVGElementTagNameMap>(
|
||||
parent: Element,
|
||||
@@ -462,6 +483,33 @@ export function renderDiagram(
|
||||
parent.append(n);
|
||||
return n;
|
||||
};
|
||||
const label = (parent: Element, x: number, y: number, text: string, cls: string): void => {
|
||||
const tag = add(parent, "g", { class: cls });
|
||||
const box = add(tag, "rect", { rx: "4", ry: "4" });
|
||||
const t = add(tag, "text", {
|
||||
x: x.toFixed(1),
|
||||
y: y.toFixed(1),
|
||||
"dominant-baseline": "central",
|
||||
"text-anchor": "middle",
|
||||
});
|
||||
t.textContent = text;
|
||||
const w = (t.getComputedTextLength?.() || text.length * 7) + 10;
|
||||
Object.entries({ x: x - w / 2, y: y - 9, width: w, height: 18 }).forEach(([k, v]) =>
|
||||
box.setAttribute(k, v.toFixed(1)),
|
||||
);
|
||||
};
|
||||
const defs = add(svg, "defs", {});
|
||||
const arrow = add(defs, "marker", {
|
||||
id: "m02-dfdg-arrow",
|
||||
viewBox: "0 0 8 8",
|
||||
refX: "7",
|
||||
refY: "4",
|
||||
markerWidth: "7",
|
||||
markerHeight: "7",
|
||||
orient: "auto",
|
||||
});
|
||||
add(arrow, "path", { d: "M0,0 L8,4 L0,8 z", class: "m02-dfdg__arrow" });
|
||||
const mids = new Map<string, { x: number; y: number }>();
|
||||
for (const l of lg.links) {
|
||||
const a = at(l.from, "out");
|
||||
const b = at(l.to, "in");
|
||||
@@ -474,42 +522,30 @@ export function renderDiagram(
|
||||
const traced = markOf((s) => s.some((x) => x.link === l.id));
|
||||
const mark = strong && traced === " is-dim" ? "" : traced; // 고른 선은 흐리게 하지 않음
|
||||
const dim = !mark && st.selected !== null && !strong ? " is-dim" : "";
|
||||
const rest = restLinks.has(l.id) ? " is-rest" : "";
|
||||
const g = add(svg, "g", {
|
||||
class: `m02-dfdg__link${strong ? " is-strong" : ""}${dim}${mark}`,
|
||||
class: `m02-dfdg__link${strong ? " is-strong" : ""}${dim}${mark}${rest}`,
|
||||
"data-link": l.id,
|
||||
"data-ends": [a.x, a.y, b.x, b.y].map((n) => n.toFixed(1)).join(","),
|
||||
});
|
||||
add(g, "path", { d, class: "m02-dfdg__hit" });
|
||||
add(g, "path", { d, class: "m02-dfdg__wire" });
|
||||
const mid = midOf(a, b);
|
||||
mids.set(l.id, mid);
|
||||
const rule = labels[l.id] ?? linkLabel(l);
|
||||
if (rule) {
|
||||
const tag = add(g, "g", { class: "m02-dfdg__rule" });
|
||||
const box = add(tag, "rect", { rx: "4", ry: "4" });
|
||||
const text = add(tag, "text", {
|
||||
x: mid.x.toFixed(1),
|
||||
y: mid.y.toFixed(1),
|
||||
"dominant-baseline": "central",
|
||||
"text-anchor": "middle",
|
||||
});
|
||||
text.textContent = rule;
|
||||
const w = (text.getComputedTextLength?.() || rule.length * 7) + 10;
|
||||
Object.entries({ x: mid.x - w / 2, y: mid.y - 9, width: w, height: 18 }).forEach(([k, v]) =>
|
||||
box.setAttribute(k, v.toFixed(1)),
|
||||
);
|
||||
}
|
||||
if (rule) label(g, mid.x, mid.y, rule, "m02-dfdg__rule");
|
||||
if (rest) label(g, mid.x, mid.y + (rule ? 18 : 0), tt("Rest"), "m02-dfdg__restmark");
|
||||
const broken = breaks.filter((k) => k.link === l.id);
|
||||
const why = [...broken, ...rests.filter((k) => k.link === l.id)]
|
||||
.map((k) => k.text)
|
||||
.join(" · ");
|
||||
if (broken.length) {
|
||||
const why = broken.map((k) => k.text).join(" · ");
|
||||
const cy = (mid.y - (rule ? 18 : 0)).toFixed(1);
|
||||
const badge = add(g, "g", { class: "m02-dfdg__linkwarn", "data-why": why });
|
||||
add(badge, "circle", {
|
||||
cx: mid.x.toFixed(1),
|
||||
cy: (mid.y - (rule ? 18 : 0)).toFixed(1),
|
||||
r: "8",
|
||||
});
|
||||
add(badge, "circle", { cx: mid.x.toFixed(1), cy, r: "8" });
|
||||
const t = add(badge, "text", {
|
||||
x: mid.x.toFixed(1),
|
||||
y: (mid.y - (rule ? 18 : 0)).toFixed(1),
|
||||
y: cy,
|
||||
"dominant-baseline": "central",
|
||||
"text-anchor": "middle",
|
||||
});
|
||||
@@ -518,10 +554,43 @@ export function renderDiagram(
|
||||
}
|
||||
g.addEventListener("click", (ev) => {
|
||||
ev.stopPropagation();
|
||||
const why = broken.map((k) => k.text).join(" · ");
|
||||
select(st.selected === l.id ? null : l.id, undefined, st.selected === l.id ? "" : why);
|
||||
});
|
||||
}
|
||||
// 기댐 선 — 규칙이 기대는 알약 → 그 규칙이 있는 곳 · 흐린 점선 · 화살
|
||||
for (const dep of depLines(lg)) {
|
||||
const a = at(dep.from, "out");
|
||||
const b =
|
||||
"link" in dep.to
|
||||
? mids.get(dep.to.link)
|
||||
: at({ box: dep.to.box, name: dep.to.port }, "out", "far");
|
||||
if (!a || !b) continue;
|
||||
const dx = Math.max(40, Math.abs(b.x - a.x) / 2);
|
||||
const d = `M${a.x},${a.y} C${a.x + dx},${a.y} ${b.x - dx},${b.y} ${b.x},${b.y}`;
|
||||
const where = "link" in dep.to ? dep.to.link : dep.to.box;
|
||||
const steps = opts.trace;
|
||||
const traced =
|
||||
steps?.back && depInTrace(dep, steps.back)
|
||||
? " is-back"
|
||||
: steps?.forward && depInTrace(dep, steps.forward)
|
||||
? " is-fwd"
|
||||
: steps && (steps.back?.length || steps.forward?.length)
|
||||
? " is-dim"
|
||||
: "";
|
||||
const g = add(svg, "g", {
|
||||
class: `m02-dfdg__dep${traced}${st.selected === where ? " is-strong" : ""}`,
|
||||
"data-dep": dep.id,
|
||||
"data-ends": [a.x, a.y, b.x, b.y].map((n) => n.toFixed(1)).join(","),
|
||||
});
|
||||
add(g, "path", { d, class: "m02-dfdg__hit" });
|
||||
add(g, "path", { d, class: "m02-dfdg__depwire", "marker-end": "url(#m02-dfdg-arrow)" });
|
||||
const m = midOf(a, b);
|
||||
label(g, m.x, m.y, dep.text, "m02-dfdg__deptext");
|
||||
g.addEventListener("click", (ev) => {
|
||||
ev.stopPropagation();
|
||||
select(where);
|
||||
});
|
||||
}
|
||||
}
|
||||
const firstFit = (): void => {
|
||||
if (st.fitted || !lg.boxes.length || !view.clientWidth) return drawLines();
|
||||
@@ -534,34 +603,6 @@ export function renderDiagram(
|
||||
if (view.isConnected) firstFit();
|
||||
|
||||
/* 끌기 셋 — 상자 · 판 · 선 */
|
||||
function follow(
|
||||
ev: PointerEvent,
|
||||
move: (m: PointerEvent) => void,
|
||||
end: (u: PointerEvent, moved: boolean) => void,
|
||||
): void {
|
||||
const x0 = ev.clientX;
|
||||
const y0 = ev.clientY;
|
||||
let moved = false;
|
||||
const mv = (m: PointerEvent): void => {
|
||||
if (!moved && Math.hypot(m.clientX - x0, m.clientY - y0) < 5) return;
|
||||
moved = true;
|
||||
move(m);
|
||||
};
|
||||
const up = (u: PointerEvent): void => {
|
||||
window.removeEventListener("pointermove", mv);
|
||||
window.removeEventListener("pointerup", up);
|
||||
if (moved) {
|
||||
// 끈 뒤 바로 따라오는 click 만 삼킴 — 안 오면 곧 풀어 다음 누름은 그대로
|
||||
const swallow = (c: MouseEvent): void => c.stopPropagation();
|
||||
window.addEventListener("click", swallow, { capture: true, once: true });
|
||||
setTimeout(() => window.removeEventListener("click", swallow, { capture: true }), 0);
|
||||
}
|
||||
end(u, moved);
|
||||
};
|
||||
window.addEventListener("pointermove", mv);
|
||||
window.addEventListener("pointerup", up);
|
||||
}
|
||||
|
||||
function dragBox(ev: PointerEvent, b: CfBox, node: HTMLElement): void {
|
||||
ev.preventDefault(); // 끌면서 글자가 잡히지 않게
|
||||
const start = toWorld(ev.clientX, ev.clientY);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_Diagram_Parts.ts
|
||||
* 단가산출 흐름도 다이어그램의 순수 부분(DOM 없음) — 글 · 약속 모양 · 떨군 자리 풀기 · 줄 맞추기 ·
|
||||
* 한눈에 맞춤 · 알약 값 글 · 흐름 길 가르기. 그리기는 `M02_DF_Diagram.ts`.
|
||||
* 한눈에 맞춤 · 알약 값 글 · 흐름 길 가르기 · 기댐 선 · 끌기 손잡이. 그리기는 `M02_DF_Diagram.ts`.
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
type CfPort,
|
||||
type CfLink,
|
||||
type CfStep,
|
||||
elementPort,
|
||||
ruleSources,
|
||||
} from "./M02_DF_Model";
|
||||
import type { CfBreak } from "./M02_DF_Eval";
|
||||
import { ruleLabel } from "./M02_DF_Rule";
|
||||
@@ -63,6 +65,13 @@ export const TEXT = {
|
||||
],
|
||||
Break: ["끊긴 곳", "Break"],
|
||||
Back: ["온 길", "Came from"],
|
||||
DepLookup: ["표 조건", "table key"],
|
||||
DepGate: ["조건", "condition"],
|
||||
DepMaterial: ["{f}에 {o}", "{f} {o}"],
|
||||
Rest: ["쉼", "Resting"],
|
||||
Empty1: ["① 「+ 입력」 으로 상자를 놓음", "① Place a box with “+ Input”"],
|
||||
Empty2: ["② 알약을 끌어 다른 상자에 놓으면 선", "② Drag a pill onto another box to draw a line"],
|
||||
Empty3: ["③ 선을 누르면 규칙", "③ Tap a line to set its rule"],
|
||||
Forward: ["가는 길", "Goes to"],
|
||||
} as const;
|
||||
|
||||
@@ -231,3 +240,81 @@ export const midOf = (
|
||||
x: (a.x + b.x) / 2,
|
||||
y: (a.y + b.y) / 2,
|
||||
});
|
||||
|
||||
/* ── 기댐 선 · 쉼 ──────────────────────────────────────────────────── */
|
||||
|
||||
/** 기댐 선 — 규칙이 기대는 알약 → 그 규칙이 있는 곳(선이면 그 선 이름표 · 재료 조건 찾기면 그 재료 알약) */
|
||||
export interface DepLine {
|
||||
id: string;
|
||||
from: CfPort;
|
||||
to: { link: string } | { box: string; port: string };
|
||||
text: string;
|
||||
}
|
||||
|
||||
export function depLines(lg: CfLogic): DepLine[] {
|
||||
const out: DepLine[] = [];
|
||||
for (const l of lg.links)
|
||||
ruleSources(l.rule).forEach((from, i) =>
|
||||
out.push({
|
||||
id: `dep:${l.id}:${i}`,
|
||||
from,
|
||||
to: { link: l.id },
|
||||
text: tt(l.rule?.kind === "lookup" ? "DepLookup" : "DepGate"),
|
||||
}),
|
||||
);
|
||||
for (const b of lg.boxes)
|
||||
for (const e of b.input?.elements ?? [])
|
||||
if (e.mode === "rule" && e.rule)
|
||||
out.push({
|
||||
id: `dep:${b.id}:${elementPort(e)}`,
|
||||
from: e.rule.from,
|
||||
to: { box: b.id, port: elementPort(e) },
|
||||
text: tt("DepMaterial", { f: e.rule.field, o: e.rule.op }),
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 기댐 선이 흐름 길에 드나 — 기대는 알약과 닿는 곳(선 · 상자)이 같은 길에 있을 때 */
|
||||
export function depInTrace(dep: DepLine, steps: CfStep[]): boolean {
|
||||
const src = steps.some((s) => s.box === dep.from.box && s.port === dep.from.name);
|
||||
const dst =
|
||||
"link" in dep.to
|
||||
? steps.some((s) => s.link === (dep.to as { link: string }).link)
|
||||
: steps.some((s) => s.box === (dep.to as { box: string }).box);
|
||||
return src && dst;
|
||||
}
|
||||
|
||||
/** 끊긴 곳 갈래 — 쉼(조건이 안 맞아 이번엔 안 지나감 · 문제 아님) · 문제 — level 이 없으면 「통과 안 됨」 을 쉼으로 */
|
||||
export const isRest = (k: CfBreak): boolean =>
|
||||
((k as CfBreak & { level?: string }).level ?? (k.kind === "통과 안 됨" ? "쉼" : "문제")) === "쉼";
|
||||
|
||||
/* ── 끌기 손잡이 ─────────────────────────────────────────────────────── */
|
||||
|
||||
/** 누른 뒤 5px 넘게 움직이면 끌기 — 끈 뒤 따라오는 click 한 번만 삼킴 */
|
||||
export function follow(
|
||||
ev: PointerEvent,
|
||||
move: (m: PointerEvent) => void,
|
||||
end: (u: PointerEvent, moved: boolean) => void,
|
||||
): void {
|
||||
const x0 = ev.clientX;
|
||||
const y0 = ev.clientY;
|
||||
let moved = false;
|
||||
const mv = (m: PointerEvent): void => {
|
||||
if (!moved && Math.hypot(m.clientX - x0, m.clientY - y0) < 5) return;
|
||||
moved = true;
|
||||
move(m);
|
||||
};
|
||||
const up = (u: PointerEvent): void => {
|
||||
window.removeEventListener("pointermove", mv);
|
||||
window.removeEventListener("pointerup", up);
|
||||
if (moved) {
|
||||
// 끈 뒤 바로 따라오는 click 만 삼킴 — 안 오면 곧 풀어 다음 누름은 그대로
|
||||
const swallow = (c: MouseEvent): void => c.stopPropagation();
|
||||
window.addEventListener("click", swallow, { capture: true, once: true });
|
||||
setTimeout(() => window.removeEventListener("click", swallow, { capture: true }), 0);
|
||||
}
|
||||
end(u, moved);
|
||||
};
|
||||
window.addEventListener("pointermove", mv);
|
||||
window.addEventListener("pointerup", up);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user