@orangeworks/orangetree 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,362 @@
1
+ import { t } from "./i18n.js";
2
+ const SVG_NS = "http://www.w3.org/2000/svg";
3
+ const COL_W = 170;
4
+ const ROW_H = 120;
5
+ const R = 24;
6
+ const DEFAULT_FIT_SCALE = 0.7;
7
+ const FIT_PAD = 72;
8
+ const SCALE_KEY = "otree-fit-scale";
9
+ const STATUS_VIEW = {
10
+ preparing: { fill: "#eeeeee", stroke: "#cccccc" },
11
+ in_progress: { fill: "#e8f1ea", stroke: "#2F6B3D" },
12
+ blocked: { fill: "#eef1f4", stroke: "#64748b" },
13
+ waiting_handoff: { fill: "#eaf2ee", stroke: "#2F6B3D" },
14
+ review: { fill: "#fbf0e4", stroke: "#B45309" },
15
+ done: { fill: "#fbe9dc", stroke: "#E0612A" }
16
+ };
17
+ function svgEl(tag, attrs) {
18
+ const e = document.createElementNS(SVG_NS, tag);
19
+ for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, String(v));
20
+ return e;
21
+ }
22
+ const GLYPHS = {
23
+ preparing: [
24
+ ["circle", { r: 7, fill: "none", stroke: "#bbb", "stroke-width": 2, "stroke-dasharray": "4 4" }]
25
+ ],
26
+ blocked: [
27
+ ["ellipse", { cx: 0, cy: 1, rx: 7, ry: 10, fill: "#64748b" }],
28
+ ["path", { d: "M 0 -9 Q 4 -1 0 7 Q -4 -1 0 -9 Z", fill: "#94a3b8" }]
29
+ ],
30
+ in_progress: [
31
+ ["path", { d: "M 0 9 Q -16 2 -11 -13 Q 5 -7 0 9 Z", fill: "#2F6B3D" }],
32
+ ["path", { d: "M 2 10 Q 15 0 10 -14 Q -5 -5 2 10 Z", fill: "#4C9A5E" }],
33
+ ["path", { d: "M 2 10 Q 5 -2 10 -14", stroke: "#2F6B3D", "stroke-width": 1.2, fill: "none" }]
34
+ ],
35
+ waiting_handoff: [
36
+ ["line", { x1: 0, y1: 14, x2: 0, y2: 3, stroke: "#2F6B3D", "stroke-width": 1.8 }],
37
+ ["path", { d: "M 0 3 Q -3 -12 0 -17 Q 3 -12 0 3 Z", fill: "#C2710C" }],
38
+ ["path", { d: "M 0 6 Q -9 1 -7 -9 Q 0 -5 0 6 Z", fill: "#3f8a52" }],
39
+ ["path", { d: "M 0 6 Q 9 1 7 -9 Q 0 -5 0 6 Z", fill: "#4C9A5E" }]
40
+ ],
41
+ review: [
42
+ ["ellipse", { cx: 0, cy: -9, rx: 4.5, ry: 7.5, fill: "#C2710C" }],
43
+ ["ellipse", { cx: 9, cy: -3, rx: 4.5, ry: 7.5, fill: "#C2710C", transform: "rotate(72 9 -3)" }],
44
+ ["ellipse", { cx: 5.5, cy: 8, rx: 4.5, ry: 7.5, fill: "#C2710C", transform: "rotate(144 5.5 8)" }],
45
+ ["ellipse", { cx: -5.5, cy: 8, rx: 4.5, ry: 7.5, fill: "#C2710C", transform: "rotate(216 -5.5 8)" }],
46
+ ["ellipse", { cx: -9, cy: -3, rx: 4.5, ry: 7.5, fill: "#C2710C", transform: "rotate(288 -9 -3)" }],
47
+ ["circle", { r: 4, fill: "#7C3A06" }]
48
+ ],
49
+ done: [
50
+ ["circle", { cx: 0, cy: 3, r: 11, fill: "#E0612A" }],
51
+ ["circle", { cx: -3.5, cy: -0.5, r: 3, fill: "#EE8A5C" }],
52
+ ["line", { x1: 0, y1: -8, x2: 0, y2: -13, stroke: "#2F6B3D", "stroke-width": 1.8 }],
53
+ ["path", { d: "M 0 -11 Q 9 -16 12 -9 Q 3 -6 0 -11 Z", fill: "#4C9A5E" }]
54
+ ]
55
+ };
56
+ function makeGlyph(status) {
57
+ const g = document.createElementNS(SVG_NS, "g");
58
+ g.setAttribute("class", "glyph");
59
+ for (const [tag, attrs] of GLYPHS[status] ?? GLYPHS.in_progress) g.append(svgEl(tag, attrs));
60
+ return g;
61
+ }
62
+ class Canvas {
63
+ svg;
64
+ onSelect;
65
+ onMove;
66
+ onContextMenu;
67
+ view;
68
+ fitScale;
69
+ nodes = [];
70
+ sig = "";
71
+ constructor(svg, { onSelect, onMove, onContextMenu }) {
72
+ this.svg = svg;
73
+ this.onSelect = onSelect;
74
+ this.onMove = onMove;
75
+ this.onContextMenu = onContextMenu;
76
+ this.view = { x: -80, y: -60, w: 1200, h: 800 };
77
+ const saved = parseFloat(localStorage.getItem(SCALE_KEY) ?? "");
78
+ this.fitScale = saved > 0 ? saved : DEFAULT_FIT_SCALE;
79
+ this.applyView();
80
+ this.bindPanZoom();
81
+ }
82
+ applyView() {
83
+ const { x, y, w, h } = this.view;
84
+ this.svg.setAttribute("viewBox", `${x} ${y} ${w} ${h}`);
85
+ }
86
+ // Remember a user-chosen zoom as the fit cap (so the next auto-fit lands the same size).
87
+ rememberScale() {
88
+ const cw = this.svg.clientWidth || 800;
89
+ const scale = Math.max(0.1, Math.min(3, cw / this.view.w));
90
+ this.fitScale = scale;
91
+ localStorage.setItem(SCALE_KEY, String(scale));
92
+ }
93
+ // Zoom about the view center (factor>1 = out, <1 = in).
94
+ zoomBy(factor) {
95
+ const cx = this.view.x + this.view.w / 2;
96
+ const cy = this.view.y + this.view.h / 2;
97
+ this.view.w *= factor;
98
+ this.view.h *= factor;
99
+ this.view.x = cx - this.view.w / 2;
100
+ this.view.y = cy - this.view.h / 2;
101
+ this.applyView();
102
+ this.rememberScale();
103
+ }
104
+ // Fit the whole tree to the top-center of the canvas (capped so nodes don't blow up).
105
+ fitView() {
106
+ const nodes = this.nodes;
107
+ if (!nodes.length) return;
108
+ let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
109
+ for (const n of nodes) {
110
+ minX = Math.min(minX, n.px);
111
+ maxX = Math.max(maxX, n.px);
112
+ minY = Math.min(minY, n.py);
113
+ maxY = Math.max(maxY, n.py);
114
+ }
115
+ const cw = this.svg.clientWidth || 800;
116
+ const ch = this.svg.clientHeight || 600;
117
+ const aspect = cw / ch;
118
+ const contentW = maxX - minX + R * 2;
119
+ const contentH = maxY - minY + R * 2;
120
+ let vw = Math.max(contentW + FIT_PAD * 2, (contentH + FIT_PAD * 2) * aspect);
121
+ const minVw = cw / this.fitScale;
122
+ if (vw < minVw) vw = minVw;
123
+ const vh = vw / aspect;
124
+ this.view.w = vw;
125
+ this.view.h = vh;
126
+ this.view.x = (minX + maxX) / 2 - vw / 2;
127
+ this.view.y = minY - R - FIT_PAD;
128
+ this.applyView();
129
+ }
130
+ bindPanZoom() {
131
+ let panning = null;
132
+ this.svg.addEventListener("pointerdown", (e) => {
133
+ if (e.target?.closest(".node")) return;
134
+ panning = { px: e.clientX, py: e.clientY, vx: this.view.x, vy: this.view.y };
135
+ this.svg.classList.add("panning");
136
+ this.svg.setPointerCapture(e.pointerId);
137
+ });
138
+ this.svg.addEventListener("pointermove", (e) => {
139
+ if (!panning) return;
140
+ const scale = this.view.w / this.svg.clientWidth;
141
+ this.view.x = panning.vx - (e.clientX - panning.px) * scale;
142
+ this.view.y = panning.vy - (e.clientY - panning.py) * scale;
143
+ this.applyView();
144
+ });
145
+ this.svg.addEventListener("pointerup", () => {
146
+ panning = null;
147
+ this.svg.classList.remove("panning");
148
+ });
149
+ this.svg.addEventListener("wheel", (e) => {
150
+ e.preventDefault();
151
+ const factor = e.deltaY > 0 ? 1.12 : 1 / 1.12;
152
+ const rect = this.svg.getBoundingClientRect();
153
+ const cx = this.view.x + (e.clientX - rect.left) / rect.width * this.view.w;
154
+ const cy = this.view.y + (e.clientY - rect.top) / rect.height * this.view.h;
155
+ this.view.w *= factor;
156
+ this.view.h *= factor;
157
+ this.view.x = cx - (cx - this.view.x) * factor;
158
+ this.view.y = cy - (cy - this.view.y) * factor;
159
+ this.applyView();
160
+ this.rememberScale();
161
+ }, { passive: false });
162
+ }
163
+ // Tree layout: assign x-slots from leaves up, parent centered over children.
164
+ layout(nodes) {
165
+ const byId = {};
166
+ for (const n of nodes) byId[n.id] = { ...n };
167
+ const children = {};
168
+ const roots = [];
169
+ for (const n of Object.values(byId)) {
170
+ if (n.parentId && byId[n.parentId]) (children[n.parentId] ??= []).push(n);
171
+ else roots.push(n);
172
+ }
173
+ const order = (a, b) => a.createdAt - b.createdAt;
174
+ let slot = 0;
175
+ const place = (n, depth) => {
176
+ const kids = (children[n.id] ?? []).sort(order);
177
+ if (!kids.length) {
178
+ n.gx = slot++;
179
+ } else {
180
+ for (const k of kids) place(k, depth + 1);
181
+ n.gx = (kids[0].gx + kids[kids.length - 1].gx) / 2;
182
+ }
183
+ n.gy = depth;
184
+ };
185
+ for (const r of roots.sort(order)) {
186
+ place(r, 0);
187
+ slot++;
188
+ }
189
+ for (const n of Object.values(byId)) {
190
+ if (n.pos && typeof n.pos.x === "number") {
191
+ n.px = n.pos.x;
192
+ n.py = n.pos.y;
193
+ } else {
194
+ n.px = n.gx * COL_W;
195
+ n.py = n.gy * ROW_H;
196
+ }
197
+ }
198
+ return { byId };
199
+ }
200
+ // tree.nodes = full layout set (whole project). visibleIds = ids actually shown (null = all).
201
+ // dimmedIds = subset of shown ids drawn faded (archived nodes under '아카이브 표시'). S7.
202
+ render(tree, selectedId, candidates = /* @__PURE__ */ new Set(), busy = /* @__PURE__ */ new Set(), visibleIds = null, dimmedIds = /* @__PURE__ */ new Set()) {
203
+ const nodes = Object.values(tree.nodes ?? {});
204
+ this.svg.replaceChildren();
205
+ this.nodes = [];
206
+ if (!nodes.length) {
207
+ this.sig = "";
208
+ return;
209
+ }
210
+ const { byId } = this.layout(nodes);
211
+ const isVisible = (id) => !visibleIds || visibleIds.has(id);
212
+ const shown = Object.values(byId).filter((n) => isVisible(n.id));
213
+ this.nodes = shown;
214
+ const edges = document.createElementNS(SVG_NS, "g");
215
+ const nodeLayer = document.createElementNS(SVG_NS, "g");
216
+ this.svg.append(edges, nodeLayer);
217
+ for (const n of shown) {
218
+ if (n.parentId && byId[n.parentId] && isVisible(n.parentId)) {
219
+ const p = byId[n.parentId];
220
+ edges.append(this.edge(p.px, p.py + R, n.px, n.py - R, false));
221
+ }
222
+ for (const dep of n.dependsOn ?? []) {
223
+ const d = byId[dep];
224
+ if (d && isVisible(dep)) edges.append(this.edge(d.px, d.py, n.px, n.py, true));
225
+ }
226
+ nodeLayer.append(this.node(n, n.id === selectedId, candidates.has(n.id), busy.has(n.id), dimmedIds.has(n.id)));
227
+ }
228
+ const sig = this.nodes.map((n) => n.id).sort().join(",");
229
+ if (sig !== this.sig) {
230
+ this.sig = sig;
231
+ this.fitView();
232
+ }
233
+ }
234
+ // Called during drag: redraw only the edge layer from current px/py.
235
+ redrawEdges() {
236
+ const edgeLayer = this.svg.querySelector("g");
237
+ if (!edgeLayer || !this.nodes.length) return;
238
+ const byId = {};
239
+ for (const n of this.nodes) byId[n.id] = n;
240
+ edgeLayer.replaceChildren();
241
+ for (const n of this.nodes) {
242
+ if (n.parentId && byId[n.parentId]) {
243
+ const p = byId[n.parentId];
244
+ edgeLayer.append(this.edge(p.px, p.py + R, n.px, n.py - R, false));
245
+ }
246
+ for (const dep of n.dependsOn ?? []) {
247
+ const d = byId[dep];
248
+ if (d) edgeLayer.append(this.edge(d.px, d.py, n.px, n.py, true));
249
+ }
250
+ }
251
+ }
252
+ edge(x1, y1, x2, y2, isDep) {
253
+ const path = document.createElementNS(SVG_NS, "path");
254
+ const my = (y1 + y2) / 2;
255
+ path.setAttribute("d", `M ${x1} ${y1} C ${x1} ${my}, ${x2} ${my}, ${x2} ${y2}`);
256
+ path.setAttribute("class", isDep ? "edge dep" : "edge");
257
+ return path;
258
+ }
259
+ rimBadge(symbol, fill, size = 16) {
260
+ const t2 = document.createElementNS(SVG_NS, "text");
261
+ t2.setAttribute("x", "18");
262
+ t2.setAttribute("y", "-18");
263
+ t2.setAttribute("text-anchor", "middle");
264
+ t2.setAttribute("dominant-baseline", "central");
265
+ t2.style.fill = fill;
266
+ t2.style.fontSize = `${size}px`;
267
+ t2.style.fontWeight = "800";
268
+ t2.style.pointerEvents = "none";
269
+ t2.textContent = symbol;
270
+ return t2;
271
+ }
272
+ node(n, selected, candidate, busy, dimmed = false) {
273
+ const view = STATUS_VIEW[n.status] ?? STATUS_VIEW.in_progress;
274
+ const g = document.createElementNS(SVG_NS, "g");
275
+ g.setAttribute("class", `node${selected ? " selected" : ""}${n.status === "preparing" ? " preparing" : ""}${dimmed ? " dimmed" : ""}`);
276
+ g.setAttribute("transform", `translate(${n.px}, ${n.py})`);
277
+ g.dataset.id = n.id;
278
+ const body = document.createElementNS(SVG_NS, "circle");
279
+ body.setAttribute("class", "body");
280
+ body.setAttribute("r", String(R));
281
+ body.setAttribute("fill", view.fill);
282
+ body.setAttribute("stroke", view.stroke);
283
+ if (busy) {
284
+ for (const delay of ["0s", "0.8s"]) {
285
+ const ring = document.createElementNS(SVG_NS, "circle");
286
+ ring.setAttribute("class", "busy-ring");
287
+ ring.setAttribute("r", String(R));
288
+ ring.style.animationDelay = delay;
289
+ g.append(ring);
290
+ }
291
+ }
292
+ const icon = makeGlyph(n.status);
293
+ const otid = document.createElementNS(SVG_NS, "text");
294
+ otid.setAttribute("class", "otid");
295
+ otid.setAttribute("y", String(-R - 8));
296
+ otid.textContent = n.id;
297
+ const label = document.createElementNS(SVG_NS, "text");
298
+ label.setAttribute("class", "label");
299
+ label.setAttribute("y", String(R + 16));
300
+ label.textContent = n.title.length > 18 ? `${n.title.slice(0, 17)}\u2026` : n.title;
301
+ if (n.mergeState === "conflict") {
302
+ g.append(this.rimBadge("!", "#dc2626"));
303
+ } else if (n.warn) {
304
+ const w = this.rimBadge("!", "#dc2626");
305
+ const t2 = document.createElementNS(SVG_NS, "title");
306
+ t2.textContent = n.warn;
307
+ w.append(t2);
308
+ w.style.pointerEvents = "auto";
309
+ g.append(w);
310
+ } else if ((n.notices ?? []).some((x) => x.kind === "code-change" && !x.delivered && !x.ack)) {
311
+ g.append(this.rimBadge("\u27F3", "#e8740c", 15));
312
+ }
313
+ if (candidate) {
314
+ const ring = document.createElementNS(SVG_NS, "circle");
315
+ ring.setAttribute("r", String(R + 6));
316
+ ring.setAttribute("class", "candidate-ring");
317
+ g.prepend(ring);
318
+ const hint = document.createElementNS(SVG_NS, "text");
319
+ hint.setAttribute("class", "otid");
320
+ hint.setAttribute("y", String(-R - 20));
321
+ hint.textContent = t("canvas.candidate.hint");
322
+ g.append(hint);
323
+ }
324
+ g.append(body, icon, otid, label);
325
+ g.addEventListener("pointerdown", (e) => {
326
+ e.stopPropagation();
327
+ const scale = this.view.w / this.svg.clientWidth;
328
+ const start = { px: e.clientX, py: e.clientY, x: n.px, y: n.py, moved: false };
329
+ const onMove = (ev) => {
330
+ if (!start.moved && Math.hypot(ev.clientX - start.px, ev.clientY - start.py) < 4) return;
331
+ start.moved = true;
332
+ const dx = (ev.clientX - start.px) * scale;
333
+ const dy = (ev.clientY - start.py) * scale;
334
+ n.px = start.x + dx;
335
+ n.py = start.y + dy;
336
+ g.setAttribute("transform", `translate(${n.px}, ${n.py})`);
337
+ this.redrawEdges();
338
+ };
339
+ const onUp = () => {
340
+ window.removeEventListener("pointermove", onMove);
341
+ window.removeEventListener("pointerup", onUp);
342
+ if (start.moved) this.onMove(n.id, { x: Math.round(n.px), y: Math.round(n.py) });
343
+ else this.onSelect(n.id);
344
+ };
345
+ window.addEventListener("pointermove", onMove);
346
+ window.addEventListener("pointerup", onUp);
347
+ });
348
+ g.addEventListener("dblclick", (e) => {
349
+ e.stopPropagation();
350
+ this.onMove(n.id, null);
351
+ });
352
+ g.addEventListener("contextmenu", (e) => {
353
+ e.preventDefault();
354
+ e.stopPropagation();
355
+ this.onContextMenu?.(n.id, e.clientX, e.clientY);
356
+ });
357
+ return g;
358
+ }
359
+ }
360
+ export {
361
+ Canvas
362
+ };