@cardenelabs/dragon 0.36.4 → 0.37.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.
@@ -2,6 +2,8 @@
2
2
  * 箱と区画の印の形。どれも (0, 0) を中心に 20 四方へ収める。
3
3
  *
4
4
  * 見本帳 (`docs/design/base/engine.js` の `G`) と同じ形を持つ。`block` だけは見本帳が絵を手で描いていたので、ここで足した。
5
+ * `undo` (戻る矢印) と `yen` (円の印) は、注文の試作 (`docs/design/base/scenes/order.js`) が返品と返金に足した形 (#3171)。
6
+ * package の入口からも出し、見本帳の部品「絵」の頁がこの表から使える絵柄の一覧を作る。1 つずつの絵は `iconSvg` が描く (#3172)。
5
7
  */
6
8
  export const ICONS: Readonly<Record<string, string>> = {
7
9
  app: "M -9 -7 h18 a2 2 0 0 1 2 2 v10 a2 2 0 0 1 -2 2 h-18 a2 2 0 0 1 -2 -2 v-10 a2 2 0 0 1 2 -2 z M -11 -2 h22",
@@ -34,6 +36,8 @@ export const ICONS: Readonly<Record<string, string>> = {
34
36
  branch: "M -4 -4.5 v9 M 5 0.5 c0 4 -9 3 -9 6.5 M -6.2 -7 a2.2 2.2 0 1 0 4.4 0 a2.2 2.2 0 1 0 -4.4 0 M -6.2 7 a2.2 2.2 0 1 0 4.4 0 a2.2 2.2 0 1 0 -4.4 0 M 2.8 -2 a2.2 2.2 0 1 0 4.4 0 a2.2 2.2 0 1 0 -4.4 0",
35
37
  cloud: "M -7 6 h13 a4.5 4.5 0 0 0 0.5 -9 a6 6 0 0 0 -11.5 -1 a4.5 4.5 0 0 0 -2 10 z",
36
38
  refresh: "M 7 -3 a7.5 7.5 0 1 0 0.5 5 M 7.5 -8 v5 h-5",
39
+ undo: "M -7 -2 h10 a5 5 0 0 1 0 10 h-6 M -3 -6 l-4 4 l4 4",
40
+ yen: "M -6 -8 l6 7 l6 -7 M 0 -1 v9 M -5 1.5 h10 M -5 5 h10",
37
41
  layers: "M 0 -8 l9 4.5 l-9 4.5 l-9 -4.5 z M -9 2.5 l9 4.5 l9 -4.5",
38
42
  code: "M -4 -6 l-5 6 l5 6 M 4 -6 l5 6 l-5 6",
39
43
  paint: "M -8 8 c 0 -3 2 -5 4.5 -5 l 9 -9 l 3.5 3.5 l -9 9 c 0 2.5 -2 4.5 -5 4.5 z",
@@ -5,7 +5,21 @@
5
5
  * 色は色の役の変数 (`--c` と `--ct`) だけを渡し、値は色味の表が持つ。
6
6
  */
7
7
  import { hasWide, textWidth } from "../layout/measure";
8
- import { along, type Point, pathLength, type SceneAction, type SceneBadge, type SceneBox, type SceneEdge, type SceneLabel, type SceneRegion, type SceneStrip } from "../layout/scene";
8
+ import {
9
+ along,
10
+ type Point,
11
+ pathLength,
12
+ type SceneAction,
13
+ type SceneBadge,
14
+ type SceneBox,
15
+ type SceneEdge,
16
+ type SceneEnd,
17
+ type SceneLabel,
18
+ type ScenePath,
19
+ type SceneRegion,
20
+ type SceneSide,
21
+ type SceneStrip,
22
+ } from "../layout/scene";
9
23
  import { type ColorKey, EASE, LOOK, MOTION, TYPE } from "../look/values";
10
24
  import type { CarrierTime, EdgeTime, LabelTime, Span, Timeline } from "../timeline";
11
25
  import { ICONS } from "./icons";
@@ -22,7 +36,36 @@ export function glyph(name: string | undefined, x: number, y: number, scale: num
22
36
  return d ? `<path class="${cls}" transform="translate(${r1(x)} ${r1(y)}) scale(${scale})" d="${d}"/>` : "";
23
37
  }
24
38
 
25
- export const pathOf = (points: Point[]): string => points.map(([x, y], i) => `${i === 0 ? "M" : "L"} ${r1(x)} ${r1(y)}`).join(" ");
39
+ /**
40
+ * 絵柄を 1 つだけ描いた SVG (#3172)。見本帳の部品「絵」の頁が、使える絵柄を並べる時に使う。
41
+ * 線の太さは箱の印と同じ割合 (印の線の太さ × 印の縮み) で、色は置いた所の字の色 (`currentColor`) を使う。知らない名前は `undefined`
42
+ */
43
+ export function iconSvg(name: string): string | undefined {
44
+ if (!Object.hasOwn(ICONS, name)) return undefined;
45
+ const width = LOOK.stroke.glyph * LOOK.box.iconScale;
46
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="-12 -12 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="${width}" stroke-linecap="round" stroke-linejoin="round"><path d="${ICONS[name]}"/></svg>`;
47
+ }
48
+
49
+ /** 点を辿る線。曲がる所は共通の値の表の半径 (`LOOK.edge.bend`) で丸め、直角の角を作らない (#3171)。 */
50
+ export function pathOf(points: Point[]): string {
51
+ const pts = points.filter((p, i) => i === 0 || p[0] !== (points[i - 1] as Point)[0] || p[1] !== (points[i - 1] as Point)[1]);
52
+ const [x0, y0] = pts[0] as Point;
53
+ let d = `M ${r1(x0)} ${r1(y0)}`;
54
+ for (let i = 1; i < pts.length - 1; i++) {
55
+ const [ax, ay] = pts[i - 1] as Point;
56
+ const [bx, by] = pts[i] as Point;
57
+ const [cx, cy] = pts[i + 1] as Point;
58
+ const l1 = Math.hypot(bx - ax, by - ay);
59
+ const l2 = Math.hypot(cx - bx, cy - by);
60
+ const a = Math.min(LOOK.edge.bend, l1 / 2, l2 / 2);
61
+ d += ` L ${r1(bx - ((bx - ax) / l1) * a)} ${r1(by - ((by - ay) / l1) * a)} Q ${r1(bx)} ${r1(by)} ${r1(bx + ((cx - bx) / l2) * a)} ${r1(by + ((cy - by) / l2) * a)}`;
62
+ }
63
+ if (pts.length > 1) {
64
+ const [lx, ly] = pts[pts.length - 1] as Point;
65
+ d += ` L ${r1(lx)} ${r1(ly)}`;
66
+ }
67
+ return d;
68
+ }
26
69
 
27
70
  /** 矢じり。線の最後の 2 点の向きに開く。 */
28
71
  export function arrowHead(points: Point[]): string {
@@ -95,11 +138,14 @@ function cardFrame(box: SceneBox, ctx: Ctx, act: string | undefined, y: number,
95
138
  return `${act ? `<g class="dg-a" style="${act}">${rect("dg-halo")}</g>` : ""}${rect("dg-st", ` ${ctx.sh}`)}${act ? `<g class="dg-a" style="${act}">${rect("dg-on")}</g>` : ""}`;
96
139
  }
97
140
 
98
- /** 箱。動く段は縁に色を付け、外側に淡い光を足す。下の小さな字は起きたことに合わせて書き換わる。 */
99
- export function card(box: SceneBox, ctx: Ctx, on: number[], changes: SubChange[]): string {
141
+ /**
142
+ * 箱。動く段は縁に色を付け、外側に淡い光を足す。下の小さな字は起きたことに合わせて書き換わる。
143
+ * `lit` を渡すと、動く段ではなくその間だけ光らせる (状態遷移図の、運ぶ物が乗っている状態)。
144
+ */
145
+ export function card(box: SceneBox, ctx: Ctx, on: number[], changes: SubChange[], lit?: Span[]): string {
100
146
  const { icon, iconRadius, iconScale, iconY, nameY, subY } = LOOK.box;
101
147
  const subs = subLines(box, ctx.m, changes, box.cy + subY);
102
- const act = on.length > 0 ? activeKf(ctx, on) : undefined;
148
+ const act = lit ? (lit.length > 0 ? rangeKf(ctx.m, lit.map((s) => [s.start, s.end])) : undefined) : on.length > 0 ? activeKf(ctx, on) : undefined;
103
149
  const mark =
104
150
  box.icon && ICONS[box.icon]
105
151
  ? `<rect class="dg-ic" x="${r1(box.cx - icon / 2)}" y="${r1(box.cy + iconY - icon / 2)}" width="${icon}" height="${icon}" rx="${iconRadius}"/>${glyph(box.icon, box.cx, box.cy + iconY, iconScale)}`
@@ -121,6 +167,59 @@ export function headCard(box: SceneBox, ctx: Ctx, on: number[], changes: SubChan
121
167
  return `<g style="${cs(box.color)}">${cardFrame(box, ctx, act, pad, h)}<text class="dg-sn" x="${r1(box.cx)}" y="${r1(pad + nameY)}" text-anchor="middle">${esc(box.name)}</text>${subs}</g>`;
122
168
  }
123
169
 
170
+ /** 状態遷移図の、どの段も辿り着かない状態。点線の縁の札で、絵柄を左に、名前と下の字を右に並べる (#3171)。光らず、字も書き換わらない */
171
+ export function ghostCard(box: SceneBox): string {
172
+ const { icon, iconRadius, iconScale, radius } = LOOK.box;
173
+ const G = LOOK.state.ghost;
174
+ const ix = box.x + G.pad + icon / 2;
175
+ const tx = box.x + G.pad + icon + G.gap;
176
+ const mark = box.icon && ICONS[box.icon] ? `<rect class="dg-ic" x="${r1(ix - icon / 2)}" y="${r1(box.cy - icon / 2)}" width="${icon}" height="${icon}" rx="${iconRadius}"/>${glyph(box.icon, ix, box.cy, iconScale)}` : "";
177
+ const nameY = box.sub ? G.nameY : LOOK.baseline.mark;
178
+ const sub = box.sub ? `<text class="dg-ss" x="${r1(tx)}" y="${r1(box.cy + G.subY)}" text-anchor="start">${esc(box.sub)}</text>` : "";
179
+ return (
180
+ `<g data-ghost="${esc(box.id)}" style="${cs(box.color)}"><rect class="dg-ghost" x="${r1(box.x)}" y="${r1(box.y)}" width="${r1(box.w)}" height="${box.h}" rx="${radius}"/>` +
181
+ `${mark}<text class="dg-sn" x="${r1(tx)}" y="${r1(box.cy + nameY)}" text-anchor="start">${esc(box.name)}</text>${sub}</g>`
182
+ );
183
+ }
184
+
185
+ /** 状態遷移図の、出た状態の右上の済んだ印。`at` に弾んで出て、繰り返す前に消える */
186
+ export function doneMark(box: SceneBox, at: number, ctx: Ctx): string {
187
+ const { r, inset } = LOOK.state.done;
188
+ const x = box.x + box.w - inset;
189
+ const y = box.y + inset;
190
+ return `<g class="dg-popc dg-a" data-done="${esc(box.id)}" style="${cs(box.color)};${ctx.m.pop(at)}"><circle class="dg-done" cx="${r1(x)}" cy="${r1(y)}" r="${r}"/>${glyph("check", x, y, 0.5, "dg-done-ck")}</g>`;
191
+ }
192
+
193
+ /** 状態遷移図の通らない道。点線で道と向きだけを見せ、運ぶ物を走らせない */
194
+ export function path(p: ScenePath, color: ColorKey): string {
195
+ const label = p.label
196
+ ? `<text class="dg-path-t${p.label.family === "sans" ? " dg-path-tj" : ""}" x="${r1(p.label.x + LOOK.label.padX)}" y="${r1(p.label.y + p.label.h / 2 + LOOK.baseline.label)}">${esc(p.label.text)}</text>`
197
+ : "";
198
+ return `<g data-path="${esc(`${p.from}->${p.to}`)}" style="${cs(color)}"><path class="dg-path" d="${pathOf(p.points)}"/><path class="dg-path-h" d="${arrowHead(p.points)}"/>${label}</g>`;
199
+ }
200
+
201
+ /** 状態遷移図の脇道の囲み。左上に囲みの名前を、その下に添え書きを 1 行ずつ置く */
202
+ export function side(s: SceneSide, color: ColorKey): string {
203
+ const { pad } = LOOK.state.side;
204
+ const title = s.y + pad + TYPE.regionName.size;
205
+ const notes = s.notes.map((note, k) => `<text class="dg-side-n" x="${r1(s.x + pad)}" y="${r1(title + LOOK.region.line * (k + 1))}">${esc(note)}</text>`).join("");
206
+ return (
207
+ `<g style="${cs(color)}"><rect class="dg-side" x="${r1(s.x + 0.5)}" y="${r1(s.y + 0.5)}" width="${r1(s.w - 1)}" height="${r1(s.h - 1)}" rx="${LOOK.region.radius}"/>` +
208
+ `<text class="dg-side-t" x="${r1(s.x + pad)}" y="${r1(title)}">${esc(s.title)}</text>${notes}</g>`
209
+ );
210
+ }
211
+
212
+ /** 状態遷移図の始まりと終わりの印。始まりは塗った丸、終わりは輪の中の丸で、脇道の中の終わりは点線の輪 */
213
+ export function endMark(e: SceneEnd, color: ColorKey): string {
214
+ const { end } = LOOK.state;
215
+ const text = e.text === "" ? "" : `<text class="dg-mark" x="${r1(e.x + LOOK.state.text)}" y="${r1(e.y + LOOK.baseline.mark)}">${esc(e.text)}</text>`;
216
+ const x = r1(e.x);
217
+ const y = r1(e.y);
218
+ if (e.kind === "start") return `<g data-end="start"><circle class="dg-start" cx="${x}" cy="${y}" r="${e.r}"/>${text}</g>`;
219
+ if (e.ghost) return `<g data-end="finish" style="${cs(color)}"><circle class="dg-end-x" cx="${x}" cy="${y}" r="${e.r}"/><circle class="dg-end-xi" cx="${x}" cy="${y}" r="${end.inner}"/>${text}</g>`;
220
+ return `<g data-end="finish"><circle class="dg-end" cx="${x}" cy="${y}" r="${e.r}"/><circle class="dg-end-in" cx="${x}" cy="${y}" r="${end.inner}"/>${text}</g>`;
221
+ }
222
+
124
223
  export function lifeline(x: number, y1: number, y2: number): string {
125
224
  return `<line class="dg-ll" x1="${r1(x)}" y1="${r1(y1)}" x2="${r1(x)}" y2="${r1(y2)}"/>`;
126
225
  }
@@ -128,20 +227,27 @@ export function lifeline(x: number, y1: number, y2: number): string {
128
227
  /**
129
228
  * 線。点線で道を見せておき、その段で色の線を引いて、段の終わりまで流れる点で向きを見せる。
130
229
  * 済んだ段は薄く残し、最後の段の後に通った道を全て濃くし直す。
230
+ * `past` を立てると (状態遷移図、#3171)、段が終わった後は色の線を消して灰の実線に替え、最後の段まで残す。
131
231
  */
132
- export function edge(e: SceneEdge, time: EdgeTime, ctx: Ctx): { base: string; lit: string } {
232
+ export function edge(e: SceneEdge, time: EdgeTime, ctx: Ctx, past = false): { base: string; lit: string } {
133
233
  const { m } = ctx;
134
234
  const d = pathOf(e.points);
135
235
  const head = arrowHead(e.points);
136
236
  const { start: a, drawn: b, end } = time;
137
- const tail: Frame[] = [
138
- [end, { opacity: 1 }, EASE.inOut],
139
- [end + 0.4, { opacity: 0.45 }],
140
- [ctx.end + 0.3, { opacity: 0.45 }, EASE.inOut],
141
- [ctx.end + 1, { opacity: 0.95 }],
142
- [m.last, { opacity: 0.95 }, EASE.inOut],
143
- [m.total, { opacity: 0 }],
144
- ];
237
+ const tail: Frame[] = past
238
+ ? [
239
+ [end, { opacity: 1 }, EASE.inOut],
240
+ [end + 0.4, { opacity: 0 }],
241
+ [m.total, { opacity: 0 }],
242
+ ]
243
+ : [
244
+ [end, { opacity: 1 }, EASE.inOut],
245
+ [end + 0.4, { opacity: 0.45 }],
246
+ [ctx.end + 0.3, { opacity: 0.45 }, EASE.inOut],
247
+ [ctx.end + 1, { opacity: 0.95 }],
248
+ [m.last, { opacity: 0.95 }, EASE.inOut],
249
+ [m.total, { opacity: 0 }],
250
+ ];
145
251
  const line = m.kf([
146
252
  [0, { opacity: 0, "stroke-dashoffset": 1 }],
147
253
  [a, { opacity: 0, "stroke-dashoffset": 1 }, EASE.inOut],
@@ -161,17 +267,21 @@ export function edge(e: SceneEdge, time: EdgeTime, ctx: Ctx): { base: string; li
161
267
  [end + 0.2, { opacity: 0, "stroke-dashoffset": run }],
162
268
  [m.total, { opacity: 0, "stroke-dashoffset": run }],
163
269
  ]);
270
+ const grey = past ? m.kf([[0, { opacity: 0 }], [end, { opacity: 0 }, EASE.inOut], [end + 0.4, { opacity: 1 }], [m.last, { opacity: 1 }, EASE.inOut], [m.total, { opacity: 0 }]]) : "";
164
271
  return {
165
- base: `<path class="dg-e" d="${d}"/><path class="dg-eh" d="${head}"/>`,
272
+ base: `<path class="dg-e" d="${d}"/><path class="dg-eh" d="${head}"/>${past ? `<path class="dg-e-past dg-a" d="${d}" style="${grey}"/><path class="dg-eh-past dg-a" d="${head}" style="${grey}"/>` : ""}`,
166
273
  lit: `<g style="${cs(e.color)}"><path class="dg-e-on dg-a" pathLength="1" d="${d}" style="${line}"/><path class="dg-eh-on dg-a" d="${head}" style="${hk}"/><path class="dg-flow dg-a" d="${d}" style="${flow}"/></g>`,
167
274
  };
168
275
  }
169
276
 
170
- /** 線の名前の札。その段の間だけ出し、段の終わりまでに消え終わる。 */
277
+ /** 線の名前の札。その段の間だけ出し、段の終わりまでに消え終わる。時刻の表が `after` を持つ時は、その間だけ札を外した灰の字で薄く残す (#3171)。 */
171
278
  export function label(l: SceneLabel, time: LabelTime, ctx: Ctx): string {
172
279
  const ja = l.family === "sans" ? " dg-capj" : "";
173
280
  const { radius } = LOOK.label;
174
- return `<g class="dg-cap${ja} dg-a" style="${cs(l.color)};${ctx.m.rise(time.start, time.end, true)}"><rect x="${r1(l.x)}" y="${r1(l.y)}" width="${r1(l.w)}" height="${l.h}" rx="${radius}"/><text x="${r1(l.x + l.w / 2)}" y="${r1(l.y + l.h / 2 + LOOK.baseline.label)}" text-anchor="middle">${esc(l.text)}</text></g>`;
281
+ const x = r1(l.x + l.w / 2);
282
+ const y = r1(l.y + l.h / 2 + LOOK.baseline.label);
283
+ const past = time.after ? `<g class="dg-cap-past${l.family === "sans" ? " dg-cap-pastj" : ""} dg-a" style="${ctx.m.vis(time.after.start, time.after.end)}"><text x="${x}" y="${y}" text-anchor="middle">${esc(l.text)}</text></g>` : "";
284
+ return `${past}<g class="dg-cap${ja} dg-a" style="${cs(l.color)};${ctx.m.rise(time.start, time.end, true)}"><rect x="${r1(l.x)}" y="${r1(l.y)}" width="${r1(l.w)}" height="${l.h}" rx="${radius}"/><text x="${x}" y="${y}" text-anchor="middle">${esc(l.text)}</text></g>`;
175
285
  }
176
286
 
177
287
  /** 時系列図の箱の中の処理の札。その段の間だけ生存線の上に出す。 */
@@ -215,8 +325,11 @@ export function carrier(e: SceneEdge, time: CarrierTime, ctx: Ctx): string {
215
325
  return `<g class="dg-a" style="${m.move(pts, m.blink(from + 0.02, gone, fade))}">${carrierShape(e, ctx.sh)}</g>`;
216
326
  }
217
327
 
218
- /** 段の番号。まだ来ていない段は出さず、今の段は塗り、済んだ段は縁取りに変えて番号を読めるまま残す。 */
219
- export function badge(b: SceneBadge, ctx: Ctx): string {
328
+ /**
329
+ * 段の番号。まだ来ていない段は出さず、今の段は塗り、済んだ段は縁取りに変えて番号を読めるまま残す。
330
+ * `grey` を立てると (状態遷移図、#3171)、済んだ段の縁取りを線と同じ灰にする。
331
+ */
332
+ export function badge(b: SceneBadge, ctx: Ctx, grey = false): string {
220
333
  const { m } = ctx;
221
334
  const span = ctx.t.steps[b.step] as Span;
222
335
  const a = span.start;
@@ -244,7 +357,7 @@ export function badge(b: SceneBadge, ctx: Ctx): string {
244
357
  const no = b.step + 1;
245
358
  return (
246
359
  `<g class="dg-bd" data-step="${b.step}" style="${cs(b.color)}"><circle class="dg-bdr dg-popc dg-a" cx="${x}" cy="${y}" r="${rr}" style="${ring}"/>` +
247
- `<g class="dg-a" style="${past}"><circle class="dg-bdp" cx="${x}" cy="${y}" r="${rr}"/><text class="dg-bdpt" x="${x}" y="${ty}" text-anchor="middle">${no}</text></g>` +
360
+ `<g class="dg-a" style="${past}"><circle class="${grey ? "dg-bdp-g" : "dg-bdp"}" cx="${x}" cy="${y}" r="${rr}"/><text class="${grey ? "dg-bdpt-g" : "dg-bdpt"}" x="${x}" y="${ty}" text-anchor="middle">${no}</text></g>` +
248
361
  `<g class="dg-popc dg-a" style="${fill}"><circle class="dg-bdf" cx="${x}" cy="${y}" r="${r}"/><text class="dg-bdft" x="${x}" y="${ty}" text-anchor="middle">${no}</text></g></g>`
249
362
  );
250
363
  }
@@ -255,9 +368,16 @@ export interface FieldChange {
255
368
  value?: string;
256
369
  tag?: string;
257
370
  color: ColorKey;
371
+ /** 変わった段と、その段の終わり */
372
+ step: number;
373
+ until: number;
258
374
  }
259
375
 
260
- export function strip(s: SceneStrip, changes: Map<string, FieldChange[]>, ctx: Ctx): string {
376
+ /**
377
+ * 中身の帯。`now` を立てると (状態遷移図、#3171)、段の中で変わった値をその段の終わりまで線の色 (今の状態の色) で描き、
378
+ * 欄の名前の横に同じ色の点を置く。帯の通った状態の欄は、`arrivals` が持つ着いた時刻に点を塗る。
379
+ */
380
+ export function strip(s: SceneStrip, changes: Map<string, FieldChange[]>, ctx: Ctx, now = false, arrivals = new Map<string, number>()): string {
261
381
  const { m, sh } = ctx;
262
382
  const S = LOOK.strip;
263
383
  const my = s.y + S.h / 2;
@@ -279,8 +399,19 @@ export function strip(s: SceneStrip, changes: Map<string, FieldChange[]>, ctx: C
279
399
  values.forEach((c, k) => {
280
400
  const next = values[k + 1]?.at;
281
401
  const value = c.value as string;
282
- const anim = next === undefined ? m.rise(c.at) : m.rise(c.at, next - MOTION.untilFade);
283
- parts.push(`<g class="dg-a" style="${anim}"><text class="${hasWide(value) ? "dg-fvj" : "dg-fv"}" x="${r1(field.x)}" y="${r1(field.y + S.valueY)}">${esc(value)}</text></g>`);
402
+ const cls = hasWide(value) ? "dg-fvj" : "dg-fv";
403
+ const textOf = (c: string): string => `<text class="${c}" x="${r1(field.x)}" y="${r1(field.y + S.valueY)}">${esc(value)}</text>`;
404
+ const text = textOf(cls);
405
+ if (!now) {
406
+ const anim = next === undefined ? m.rise(c.at) : m.rise(c.at, next - MOTION.untilFade);
407
+ parts.push(`<g class="dg-a" style="${anim}">${text}</g>`);
408
+ return;
409
+ }
410
+ // 変わった段の間は今の状態の色で、段が終わると墨に戻す
411
+ const until = Math.min(c.until, next ?? c.until);
412
+ const dot = `<circle class="dg-fl-now" cx="${r1(field.x + textWidth(field.name, TYPE.fieldName) + S.now.gap)}" cy="${r1(field.y + S.nameY - TYPE.fieldName.size / 3)}" r="${S.now.r}"/>`;
413
+ parts.push(`<g class="dg-fv-now dg-a" data-step="${c.step}" style="${cs(c.color)};${m.rise(c.at, until - MOTION.untilFade)}">${textOf(`${cls} dg-fv-c`)}${dot}</g>`);
414
+ if (next === undefined || next > until) parts.push(`<g class="dg-a" style="${m.vis(until - MOTION.untilFade, next === undefined ? m.last : next - MOTION.untilFade)}">${text}</g>`);
284
415
  });
285
416
  tags.forEach((c, k) => {
286
417
  const next = tags[k + 1]?.at;
@@ -295,5 +426,21 @@ export function strip(s: SceneStrip, changes: Map<string, FieldChange[]>, ctx: C
295
426
  );
296
427
  });
297
428
  });
429
+ if (s.trail) {
430
+ const t = s.trail;
431
+ const last = s.fields[s.fields.length - 1];
432
+ if (last && last.y === t.y) {
433
+ const sx = t.x - S.fieldGap / 2;
434
+ parts.push(`<line class="dg-sep" x1="${r1(sx)}" y1="${r1(t.y + S.sepInset)}" x2="${r1(sx)}" y2="${r1(t.y + S.h - S.sepInset)}"/>`);
435
+ }
436
+ parts.push(`<text class="dg-fl" x="${r1(t.x)}" y="${r1(t.y + S.nameY)}">${esc(t.title)}</text>`);
437
+ const dy = t.y + S.valueY - TYPE.fieldValue.size / 3;
438
+ t.states.forEach((state, k) => {
439
+ const cx = r1(t.x + S.trail.r + k * S.trail.step);
440
+ const at = arrivals.get(state.box);
441
+ const fill = at === undefined ? "" : `<circle class="dg-tr-fill dg-a" cx="${cx}" cy="${r1(dy)}" r="${S.trail.r}" style="${m.pop(at)}"/>`;
442
+ parts.push(`<g data-trail="${esc(state.box)}" style="${cs(state.color)}"><circle class="dg-tr-ring" cx="${cx}" cy="${r1(dy)}" r="${S.trail.r}"/>${fill}</g>`);
443
+ });
444
+ }
298
445
  return parts.join("");
299
446
  }
package/src/render/svg.ts CHANGED
@@ -10,13 +10,35 @@ import { layoutArchitecture } from "../layout/architecture";
10
10
  import { textWidth } from "../layout/measure";
11
11
  import type { Layout, Rect } from "../layout/scene";
12
12
  import { layoutSequence } from "../layout/sequence";
13
+ import { layoutState } from "../layout/state";
13
14
  import { FONTS } from "../look/fonts";
14
15
  import { type ColorKey, EASE, FONT_VARS, LOOK, MOTION, type Palette, paletteVars, resolvePalette, type TextStyle, type Theme, TYPE } from "../look/values";
15
16
  import { readDiagram } from "../version";
16
17
  import type { Diagram, ViewMode } from "../schema";
17
18
  import { buildTimeline, type Span, type Timeline } from "../timeline";
18
19
  import type { NotationLang } from "../words";
19
- import { action, badge, band, card, carrier, type Ctx, cs, edge, esc, type FieldChange, headCard, label, lifeline, strip, type SubChange } from "./parts";
20
+ import {
21
+ action,
22
+ badge,
23
+ band,
24
+ card,
25
+ carrier,
26
+ type Ctx,
27
+ cs,
28
+ doneMark,
29
+ edge,
30
+ endMark,
31
+ esc,
32
+ type FieldChange,
33
+ ghostCard,
34
+ headCard,
35
+ label,
36
+ lifeline,
37
+ path,
38
+ side,
39
+ strip,
40
+ type SubChange,
41
+ } from "./parts";
20
42
  import { Motion } from "./motion";
21
43
 
22
44
  export interface RenderOptions {
@@ -43,6 +65,8 @@ const font = (t: TextStyle): string => `font:${t.weight} ${t.size}px var(--${t.f
43
65
  const mix = (pct: number, base = "var(--surf)"): string => `color-mix(in srgb,var(--c) ${pct}%,${base})`;
44
66
  const S = LOOK.stroke;
45
67
  const X = LOOK.mix;
68
+ /** 状態遷移図で段が終わった線と番号の灰 (#3171) */
69
+ const PAST = `color-mix(in srgb,var(--mute) ${X.past}%,var(--bg))`;
46
70
 
47
71
  /** 部品の見た目。どの値も値の表から読む。 */
48
72
  const PART_CSS = [
@@ -69,15 +93,21 @@ const PART_CSS = [
69
93
  `.dg-e-on{fill:none;stroke:var(--c);stroke-width:${S.edgeOn};stroke-linecap:round;stroke-dasharray:1 2}`,
70
94
  `.dg-eh-on{fill:none;stroke:var(--c);stroke-width:${S.edgeOn};stroke-linecap:round;stroke-linejoin:round}`,
71
95
  `.dg-flow{fill:none;stroke:var(--surf);stroke-width:${S.flow};stroke-linecap:round;stroke-dasharray:1.5 ${LOOK.edge.flowDash}}`,
96
+ `.dg-e-past{fill:none;stroke:${PAST};stroke-width:${S.edge};stroke-linecap:round}`,
97
+ `.dg-eh-past{fill:none;stroke:${PAST};stroke-width:${S.arrow};stroke-linecap:round;stroke-linejoin:round}`,
72
98
  ".dg-bd{cursor:pointer}",
73
99
  ".dg-bdf{fill:var(--c)}",
74
100
  `.dg-bdft{${font(TYPE.badge)};fill:var(--on-c)}`,
75
101
  `.dg-bdr{fill:none;stroke:var(--c);stroke-width:${S.badgeRing}}`,
76
102
  `.dg-bdp{fill:${mix(X.badgePast)};stroke:${mix(X.badgePastEdge, "transparent")};stroke-width:${S.hair}}`,
77
103
  `.dg-bdpt{${font(TYPE.badge)};fill:var(--ct)}`,
104
+ `.dg-bdp-g{fill:var(--surf);stroke:${PAST};stroke-width:${S.hair}}`,
105
+ `.dg-bdpt-g{${font(TYPE.badge)};fill:var(--mute)}`,
78
106
  `.dg-cap rect,.dg-act rect{fill:${mix(X.cap)};stroke:${mix(X.capEdge, "transparent")}}`,
79
107
  `.dg-cap text{${font(TYPE.label)};fill:var(--ct)}`,
80
108
  `.dg-capj text{${font(TYPE.labelJa)}}`,
109
+ `.dg-cap-past text{${font(TYPE.label)};fill:var(--mute);fill-opacity:${LOOK.state.pastLabel}}`,
110
+ `.dg-cap-pastj text{${font(TYPE.labelJa)}}`,
81
111
  `.dg-act text{${font(TYPE.action)};fill:var(--ct)}`,
82
112
  ".dg-car{fill:var(--carry,var(--ink))}",
83
113
  `.dg-car-t{${font(TYPE.carrier)};fill:var(--bg)}`,
@@ -92,8 +122,28 @@ const PART_CSS = [
92
122
  `.dg-fl{${font(TYPE.fieldName)};fill:var(--mute)}`,
93
123
  `.dg-fv{${font(TYPE.fieldValue)};fill:var(--ink)}`,
94
124
  `.dg-fvj{${font(TYPE.fieldValueJa)};fill:var(--ink)}`,
125
+ ".dg-fv-c{fill:var(--ct)}",
126
+ ".dg-fl-now{fill:var(--c)}",
127
+ `.dg-tr-ring{fill:none;stroke:var(--line);stroke-width:${S.trail}}`,
128
+ ".dg-tr-fill{fill:var(--c)}",
95
129
  ".dg-tg rect{fill:var(--c)}",
96
130
  `.dg-tg text{${font(TYPE.tag)};fill:var(--on-c)}`,
131
+ `.dg-side{fill:color-mix(in srgb,var(--c) ${X.side}%,var(--bg));stroke:${mix(X.sideEdge, "transparent")};stroke-dasharray:4 4}`,
132
+ `.dg-side-t{${font(TYPE.regionName)};fill:var(--ct)}`,
133
+ `.dg-side-n{${font(TYPE.regionNote)};fill:var(--mute)}`,
134
+ `.dg-ghost{fill:var(--surf);stroke:${mix(X.ghostEdge, "transparent")};stroke-dasharray:4 3}`,
135
+ `.dg-path{fill:none;stroke:${mix(X.path, "transparent")};stroke-width:${S.path};stroke-linecap:round;stroke-dasharray:0 ${LOOK.edge.ghostDash}}`,
136
+ `.dg-path-h{fill:none;stroke:${mix(X.path, "transparent")};stroke-width:${S.arrow};stroke-linecap:round;stroke-linejoin:round}`,
137
+ `.dg-path-t{${font(TYPE.label)};fill:var(--ct)}`,
138
+ `.dg-path-tj{${font(TYPE.labelJa)}}`,
139
+ ".dg-start{fill:var(--ink)}",
140
+ `.dg-end{fill:var(--bg);stroke:var(--ink);stroke-width:${S.end}}`,
141
+ ".dg-end-in{fill:var(--ink)}",
142
+ `.dg-end-x{fill:var(--bg);stroke:${mix(X.path, "transparent")};stroke-width:${S.arrow};stroke-dasharray:2 2.5}`,
143
+ `.dg-end-xi{fill:${mix(X.path, "transparent")}}`,
144
+ `.dg-mark{${font(TYPE.regionNote)};fill:var(--mute)}`,
145
+ `.dg-done{fill:var(--ink);stroke:var(--surf);stroke-width:${S.done}}`,
146
+ `.dg-done-ck{fill:none;stroke:var(--surf);stroke-width:${S.glyph};stroke-linecap:round;stroke-linejoin:round}`,
97
147
  ".dg-vp-bg{fill:var(--bg)}",
98
148
  ".dg-vp-frame{fill:none;stroke:var(--hair)}",
99
149
  ".dg-hd-bg{fill:var(--bg)}",
@@ -181,7 +231,8 @@ function fieldChanges(diagram: Diagram, timeline: Timeline, layout: Layout): Map
181
231
  if (event?.type !== "strip") continue;
182
232
  const color: ColorKey = layout.badges[time.step]?.color ?? "slate";
183
233
  const list = out.get(event.field) ?? [];
184
- list.push({ at: time.at, color, ...(event.value !== undefined ? { value: event.value } : {}), ...(event.tag !== undefined ? { tag: event.tag } : {}) });
234
+ const until = (timeline.steps[time.step] as Span).end;
235
+ list.push({ at: time.at, color, step: time.step, until, ...(event.value !== undefined ? { value: event.value } : {}), ...(event.tag !== undefined ? { tag: event.tag } : {}) });
185
236
  out.set(event.field, list);
186
237
  }
187
238
  return out;
@@ -203,21 +254,40 @@ export function drawSvg(diagram: Diagram, layout: Layout, timeline: Timeline, pa
203
254
  const m = new Motion(`dg${id}`, timeline.total, timeline.last);
204
255
  const ctx: Ctx = { m, t: timeline, sh: `filter="url(#dg-sh-${id})"`, end: (timeline.steps[timeline.steps.length - 1] as Span).end };
205
256
  const on = involved(diagram);
206
- const subs = subChanges(diagram, timeline, layout.kind === "architecture");
257
+ // 時系列図の処理は生存線の上の札に出し、他の図の種類は箱の下の字に出す
258
+ const subs = subChanges(diagram, timeline, layout.kind !== "sequence");
207
259
 
208
260
  const L = { band: [] as string[], base: [] as string[], lit: [] as string[], node: [] as string[], badge: [] as string[], top: [] as string[] };
209
261
  for (const region of layout.regions) L.band.push(band(region));
210
- if (layout.strip) L.band.push(strip(layout.strip, fieldChanges(diagram, timeline, layout), ctx));
262
+ const ghostColor = layout.boxes.find((b) => b.ghost)?.color ?? "slate";
263
+ if (layout.side) L.band.push(side(layout.side, ghostColor));
264
+ // 脇道へ入る道は脇道の状態の色、それ以外の道は送る側の色で描く
265
+ const boxColor = (id: string, ghost?: boolean): ColorKey | undefined => layout.boxes.find((b) => b.id === id && (ghost === undefined || b.ghost === ghost))?.color;
266
+ for (const p of layout.paths) L.base.push(path(p, boxColor(p.to, true) ?? boxColor(p.from) ?? ghostColor));
267
+ for (const e of layout.ends) L.node.push(endMark(e, ghostColor));
268
+ // 状態遷移図は、段で変わった値を今の状態の色で描き、帯の通った状態の点を着いた時刻に塗る (#3171)
269
+ const isState = layout.kind === "state";
270
+ const arrivals = new Map((timeline.states ?? []).flatMap((s) => (s.lit[0] ? [[s.box, s.lit[0].start] as [string, number]] : [])));
271
+ if (layout.strip) L.band.push(strip(layout.strip, fieldChanges(diagram, timeline, layout), ctx, isState, arrivals));
211
272
  for (const line of layout.lifelines) L.base.push(lifeline(line.x, line.y1, line.y2));
212
273
  for (const e of layout.edges) {
213
274
  const time = timeline.edges.find((t) => t.step === e.step && t.event === e.event);
214
275
  if (!time) continue;
215
- const drawn = edge(e, time, ctx);
276
+ const drawn = edge(e, time, ctx, isState);
216
277
  L.base.push(drawn.base);
217
278
  L.lit.push(drawn.lit);
218
279
  }
219
- for (const box of layout.boxes) L.node.push(card(box, ctx, on.get(box.id) ?? [], subs.get(box.id) ?? []));
220
- for (const b of layout.badges) L.badge.push(badge(b, ctx));
280
+ const states = new Map((timeline.states ?? []).map((s) => [s.box, s]));
281
+ for (const box of layout.boxes) {
282
+ if (box.ghost) {
283
+ L.node.push(ghostCard(box));
284
+ continue;
285
+ }
286
+ const state = states.get(box.id);
287
+ L.node.push(card(box, ctx, on.get(box.id) ?? [], subs.get(box.id) ?? [], isState ? (state?.lit ?? []) : undefined));
288
+ if (state?.done !== undefined) L.node.push(doneMark(box, state.done, ctx));
289
+ }
290
+ for (const b of layout.badges) L.badge.push(badge(b, ctx, isState));
221
291
  for (const l of layout.labels) {
222
292
  const time = timeline.labels.find((t) => t.step === l.step && t.event === l.event);
223
293
  if (time) L.top.push(label(l, time, ctx));
@@ -421,7 +491,7 @@ export function renderDiagram(input: unknown, options: RenderOptions = {}): Rend
421
491
  const diagram = read.diagram;
422
492
  const palette = resolvePalette(options.palette);
423
493
  const theme: Theme = options.theme === "dark" ? "dark" : "light";
424
- const layout = diagram.kind === "sequence" ? layoutSequence(diagram) : layoutArchitecture(diagram);
494
+ const layout = diagram.kind === "sequence" ? layoutSequence(diagram) : diagram.kind === "state" ? layoutState(diagram, lang) : layoutArchitecture(diagram);
425
495
  const timeline = buildTimeline(diagram);
426
496
  return {
427
497
  svg: drawSvg(diagram, layout, timeline, palette, theme),
package/src/schema.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  import type { ErrorObject } from "ajv";
13
13
  import { type Allowed, FIELD_MESSAGES, type FieldMessages, type Forbidden } from "./field-messages";
14
14
  import validate from "./schemas/validate-diagram.js";
15
- import type { NotationLang } from "./words";
15
+ import { type NotationLang, STATE_ENDS } from "./words";
16
16
 
17
17
  export type Color = "blue" | "amber" | "green" | "violet" | "rose" | "slate" | "coin";
18
18
  /** 色の役の一覧。記法の `color:` に書ける値と、誤りの文に並べる値 */
@@ -48,6 +48,13 @@ export type DiagramEvent =
48
48
  | { type: "swap"; box: string; text: string }
49
49
  | { type: "strip"; field: string; value?: string; tag?: string };
50
50
 
51
+ /** 状態遷移図の、最初の段より前に書いた移れる道。`from` に `start`、`to` に `finish` を書ける */
52
+ export interface StatePath {
53
+ from: string;
54
+ to: string;
55
+ label?: string;
56
+ }
57
+
51
58
  export interface Step {
52
59
  title: string;
53
60
  body: string;
@@ -57,7 +64,7 @@ export interface Step {
57
64
 
58
65
  export interface Diagram {
59
66
  version: number;
60
- kind: "sequence" | "architecture";
67
+ kind: "sequence" | "architecture" | "state";
61
68
  title: string;
62
69
  view?: ViewMode;
63
70
  regions?: Region[];
@@ -65,6 +72,7 @@ export interface Diagram {
65
72
  strip?: { title: string; fields: string[] };
66
73
  steps: Step[];
67
74
  sequence?: { lifelines: string[] };
75
+ state?: { paths: StatePath[] };
68
76
  }
69
77
 
70
78
  export interface FieldError {
@@ -84,14 +92,21 @@ export interface CheckOptions {
84
92
  const EVENT_TYPES = ["edge", "action", "swap", "strip"] as const satisfies readonly DiagramEvent["type"][];
85
93
 
86
94
  /**
87
- * schema の書けない組み合わせ (`else: { not: { required: [欄] } }`) の 2 か所。誤りの場所の最後の欄で引き、根は "" で引く。
88
- * 2 か所とも ajv の誤りでは決まりの場所 (`#/else/not`) が同じになるので、誤りの場所で分ける
95
+ * schema の書けない組み合わせ (`not: { required: [欄] }`) の欄。根の 3 か所は決まりの場所で引き、運ぶ物は誤りの場所の最後の欄で引く。
96
+ * 根の `sequence` と運ぶ物の `text` は ajv の誤りでは決まりの場所 (`#/else/not`) が同じになるので、誤りの場所でも分ける
89
97
  */
98
+ const ROOT_NOT_ALLOWED: Record<string, Allowed> = {
99
+ "#/else/not": { field: "sequence", kind: "sequence" },
100
+ "#/then/not": { field: "state", kind: "state" },
101
+ "#/else/else/not": { field: "state", kind: "state" },
102
+ };
90
103
  const NOT_ALLOWED: Record<string, Allowed> = {
91
- "": { field: "sequence", kind: "sequence" },
92
104
  carry: { field: "text", kind: "tag" },
93
105
  };
94
106
 
107
+ /** 状態遷移図の箱の名札に予約の名前を書いた時の決まりの場所 */
108
+ const RESERVED_ID = /\/boxes\/items\/properties\/id\/not$/u;
109
+
95
110
  const escape = (key: string): string => key.replaceAll("~", "~0").replaceAll("/", "~1");
96
111
 
97
112
  /** `^[^…]+$` の形の決まりから書けない字を読む。違う形の決まりでは `undefined` */
@@ -124,7 +139,8 @@ function toFieldError(error: ErrorObject, errors: readonly ErrorObject[], m: Fie
124
139
  return { pointer: `${at}/${escape(key)}`, message: m.required(key) };
125
140
  }
126
141
  case "not": {
127
- const allowed = NOT_ALLOWED[at.slice(at.lastIndexOf("/") + 1)];
142
+ if (RESERVED_ID.test(error.schemaPath)) return { pointer: at, message: m.reserved(STATE_ENDS) };
143
+ const allowed = at === "" ? ROOT_NOT_ALLOWED[error.schemaPath] : NOT_ALLOWED[at.slice(at.lastIndexOf("/") + 1)];
128
144
  return { pointer: allowed ? `${at}/${allowed.field}` : at, message: m.not(allowed) };
129
145
  }
130
146
  case "anyOf": {
@@ -6,18 +6,24 @@
6
6
  "required": ["version", "kind", "title", "boxes", "steps"],
7
7
  "properties": {
8
8
  "version": { "description": "形式の版", "type": "integer", "minimum": 1 },
9
- "kind": { "description": "図の種類", "enum": ["sequence", "architecture"] },
9
+ "kind": { "description": "図の種類", "enum": ["sequence", "architecture", "state"] },
10
10
  "title": { "$ref": "#/definitions/text" },
11
11
  "view": { "description": "見せ方。書かなければ字の大きさで選ぶ。fixed は全体を止めて中だけ動かし、follow は寄って動かし全体図で今どこかを示す", "enum": ["fixed", "follow"] },
12
12
  "regions": { "type": "array", "items": { "$ref": "#/definitions/region" } },
13
13
  "boxes": { "type": "array", "minItems": 1, "items": { "$ref": "#/definitions/box" } },
14
14
  "strip": { "$ref": "#/definitions/strip" },
15
15
  "steps": { "type": "array", "minItems": 1, "items": { "$ref": "#/definitions/step" } },
16
- "sequence": { "$ref": "#/definitions/sequence" }
16
+ "sequence": { "$ref": "#/definitions/sequence" },
17
+ "state": { "$ref": "#/definitions/state" }
17
18
  },
18
19
  "if": { "required": ["kind"], "properties": { "kind": { "const": "sequence" } } },
19
- "then": { "required": ["sequence"] },
20
- "else": { "not": { "required": ["sequence"] } },
20
+ "then": { "required": ["sequence"], "not": { "required": ["state"] } },
21
+ "else": {
22
+ "not": { "required": ["sequence"] },
23
+ "if": { "required": ["kind"], "properties": { "kind": { "const": "state" } } },
24
+ "then": { "properties": { "boxes": { "items": { "properties": { "id": { "description": "始まりと終わりの印の名前は箱の名札に使えない", "not": { "enum": ["start", "finish"] } } } } } } },
25
+ "else": { "not": { "required": ["state"] } }
26
+ },
21
27
  "definitions": {
22
28
  "text": { "type": "string", "minLength": 1 },
23
29
  "id": { "type": "string", "pattern": "^[^\\s\\[\\]「」=:]+$" },
@@ -145,6 +151,26 @@
145
151
  "properties": {
146
152
  "lifelines": { "type": "array", "minItems": 1, "items": { "$ref": "#/definitions/id" } }
147
153
  }
154
+ },
155
+ "state": {
156
+ "description": "状態遷移図の種類の欄。最初の段より前に書いた、移れるが段で通るとは限らない線",
157
+ "type": "object",
158
+ "additionalProperties": false,
159
+ "required": ["paths"],
160
+ "properties": {
161
+ "paths": { "type": "array", "minItems": 1, "items": { "$ref": "#/definitions/path" } }
162
+ }
163
+ },
164
+ "path": {
165
+ "description": "移れる道。送る側に始まりの印 start、受ける側に終わりの印 finish を書ける。どの段にも出ない道は点線で描き、運ぶ物を走らせない",
166
+ "type": "object",
167
+ "additionalProperties": false,
168
+ "required": ["from", "to"],
169
+ "properties": {
170
+ "from": { "$ref": "#/definitions/id" },
171
+ "to": { "$ref": "#/definitions/id" },
172
+ "label": { "$ref": "#/definitions/text" }
173
+ }
148
174
  }
149
175
  }
150
176
  }