@cardenelabs/dragon 0.35.0 → 0.36.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.
package/src/player.ts CHANGED
@@ -6,6 +6,10 @@
6
6
  * 図そのものは読み上げから隠して同じ中身を 2 度読ませない。
7
7
  * 見せ方は枠の幅で選び (`view.ts`)、寄って追う時は窓と全体図で描き直す。
8
8
  * 釦と段の一覧の読み上げの名前と全体図の札は、渡した言葉 (`options.lang`、書かなければ日本語) で出す (#3001)。
9
+ * 段が替わるたびに `options.onStep` で今の段を知らせ、頁の側が段の札や進み具合を出せるようにする (#3031)。
10
+ * 段の本文の帯は本文の最も長い段の高さに保ち、段を送っても図の大きさと位置を変えない (#3049)。
11
+ * 帯の中身の幅が `LOOK.player.narrow` より狭い時は、段を送る釦を指で押せる大きさにして段の番号と上の段に並べ、
12
+ * 題と本文をその下の段の全幅に置く (#3057、#3069)。
9
13
  */
10
14
  import { FIGURE_TEXT } from "./figure-text";
11
15
  import { FONT_VARS, LOOK, paletteVars, resolvePalette, type TextStyle, TYPE } from "./look/values";
@@ -13,6 +17,16 @@ import { drawSvg, type RenderOptions, renderDiagram } from "./render/svg";
13
17
  import type { Span } from "./timeline";
14
18
  import { chooseView } from "./view";
15
19
 
20
+ export interface PlayerOptions extends RenderOptions {
21
+ /** 段が替わるたびに今の段 (0 から) を受け取る。置いた時にも最初の段で 1 度呼ぶ。頁の側で段の札や進み具合を出す時に使う (#3031) */
22
+ onStep?: (step: number) => void;
23
+ /**
24
+ * 図を、図の枠の中で見える高さに収める (#3047)。高さに収めると字が読めない図は寄って追う図にし、寄って追う窓と全体図をその高さに収める。
25
+ * 枠の高さが決まっている置き場 (カタログの拡大の重ね窓) で使う。枠の高さが図に合わせて伸びる置き場で使うと、描くたびに低くなる
26
+ */
27
+ fitHeight?: boolean;
28
+ }
29
+
16
30
  export interface Player {
17
31
  /** 今の段 (0 から) */
18
32
  readonly step: number;
@@ -34,15 +48,37 @@ function playerCss(): string {
34
48
  return [
35
49
  `.dg-player{display:flex;flex-direction:column;gap:${px(P.gap)};color:var(--ink)}`,
36
50
  `.dg-player .dg-frame{border-radius:${px(P.radius)};overflow:hidden}`,
37
- `.dg-bar{display:flex;align-items:flex-start;gap:${px(P.gap)}}`,
51
+ // 並べ方は帯の中身の幅で選ぶ。帯の中の 1 段 (`.dg-row`) が並べ替わり、帯の左右の余白は置いた頁が帯に付ける
52
+ `.dg-bar{container:dg-bar/inline-size}`,
53
+ `.dg-row{display:flex;align-items:flex-start;gap:${px(P.gap)}}`,
38
54
  `.dg-btns{display:flex;gap:${px(P.gap / 2)};flex:none}`,
39
55
  `.dg-btn{width:${px(P.button)};height:${px(P.button)};border-radius:${px(P.radius)};border:${px(LOOK.stroke.hair)} solid var(--hair);background:var(--surf);color:var(--ink);${font(TYPE.button)};cursor:pointer}`,
40
56
  `.dg-btn:focus-visible{outline:${px(P.focus)} solid var(--c-blue);outline-offset:${px(P.focus)}}`,
41
57
  `.dg-say{display:flex;gap:${px(P.gap)};min-width:0}`,
42
58
  `.dg-no{${font(TYPE.stepNo)};color:var(--mute);padding-top:${px(P.focus)}}`,
59
+ `.dg-txt{display:grid;grid-template-columns:minmax(0,1fr);min-width:0}`,
60
+ `.dg-txt>*{grid-area:1/1}`,
61
+ `.dg-room{visibility:hidden}`,
43
62
  `.dg-tt{${font(TYPE.stepTitle)};margin:0}`,
44
63
  `.dg-tb{${font(TYPE.stepBody)};color:var(--ink2);margin:0;white-space:pre-line}`,
45
64
  `.dg-sr{position:absolute;width:${hidden};height:${hidden};margin:-${hidden};padding:0;overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap;border:0}`,
65
+ // 狭い帯 (携帯の幅) では、段を送る釦を指で押せる大きさにし、その横に番号を置き、
66
+ // 題を 2 段目、本文を 3 段目に置いてどちらも帯の全幅を使う (#3057、#3069)。
67
+ // 釦を大きくすると横に題を置く幅が足りず題が細かく折れるため、題は釦の段に置かない。
68
+ // 題と本文を包む箱を外して写しも同じ升に重ねるので、2 段目は最も高い題、3 段目は最も長い本文の高さになる。
69
+ // 題と本文の間は広い帯と同じく空けず、釦の段の下にだけ間を取って、帯が高くなる分を抑える
70
+ `@container dg-bar (width < ${px(P.narrow)}){` +
71
+ `.dg-row{display:grid;grid-template-columns:auto minmax(0,1fr);align-items:start;gap:0 ${px(P.gap)}}` +
72
+ `.dg-say,.dg-txt,.dg-txt>*{display:contents}` +
73
+ `.dg-btns{grid-area:1/1;margin-bottom:${px(P.gap / 2)}}` +
74
+ `.dg-btn{width:${px(P.touch)};height:${px(P.touch)}}` +
75
+ `.dg-no{grid-area:1/2;display:flex;align-items:center;min-height:${px(P.touch)};padding-top:0}` +
76
+ `.dg-tt{grid-area:2/1/3/3}` +
77
+ `.dg-tb{grid-area:3/1/4/3}` +
78
+ `}`,
79
+ // 指で触る時は、広い帯でも段を送る釦を指で押せる大きさにする (#3076)。並びは横 1 列のまま変えない。
80
+ // 広い帯は本文が 2 行あれば帯の高さが変わらず、本文の無い帯だけが釦の分だけ高くなる
81
+ `@media (pointer: coarse){.dg-btn{width:${px(P.touch)};height:${px(P.touch)}}}`,
46
82
  ].join("");
47
83
  }
48
84
 
@@ -61,7 +97,7 @@ const pad = (n: number): string => String(n).padStart(2, "0");
61
97
  /** 段の番号の札 (`01/12`)。`step` は 0 から数える。 */
62
98
  export const stepNo = (step: number, count: number): string => `${pad(step + 1)}/${pad(count)}`;
63
99
 
64
- export function mountDiagram(host: HTMLElement, diagram: unknown, options: RenderOptions = {}): Player {
100
+ export function mountDiagram(host: HTMLElement, diagram: unknown, options: PlayerOptions = {}): Player {
65
101
  const doc = host.ownerDocument;
66
102
  const win = doc.defaultView;
67
103
  // 描く前に読む。描いた後に読むと、動きを嫌う人にも一瞬だけ流れてしまう
@@ -108,17 +144,41 @@ export function mountDiagram(host: HTMLElement, diagram: unknown, options: Rende
108
144
  no.className = "dg-no";
109
145
  no.dataset.dragon = "step-no";
110
146
  const text = doc.createElement("div");
147
+ text.className = "dg-txt";
111
148
  const title = doc.createElement("p");
112
149
  title.className = "dg-tt";
113
150
  title.dataset.dragon = "step-title";
114
151
  const body = doc.createElement("p");
115
152
  body.className = "dg-tb";
116
153
  body.dataset.dragon = "step-body";
117
- text.append(title, body);
154
+ const live = doc.createElement("div");
155
+ live.append(title, body);
156
+ text.append(live);
157
+ // 全部の段の題と本文の写しを今の段と同じ升に重ね、見た目からは隠して場所だけを取る。
158
+ // 帯の高さが本文の最も長い段で決まり、段を送っても図の枠が伸び縮みしない (#3049)
159
+ for (const step of steps) {
160
+ const room = doc.createElement("div");
161
+ room.className = "dg-room";
162
+ room.dataset.dragon = "caption-room";
163
+ const roomTitle = doc.createElement("p");
164
+ roomTitle.className = "dg-tt";
165
+ roomTitle.textContent = step.title;
166
+ const roomBody = doc.createElement("p");
167
+ roomBody.className = "dg-tb";
168
+ roomBody.textContent = step.body;
169
+ room.append(roomTitle, roomBody);
170
+ text.append(room);
171
+ }
118
172
  say.append(no, text);
173
+ // 狭い帯では語りの箱を外して番号と題と本文を釦と同じ升の並びに置く (#3057)。
174
+ // 箱を外した要素の読み上げからの隠し方に頼らず、見える語りの 1 つずつも読み上げから隠す
175
+ for (const shown of [no, title, body]) shown.setAttribute("aria-hidden", "true");
176
+ const row = doc.createElement("div");
177
+ row.className = "dg-row";
178
+ row.append(buttons, say);
119
179
  const bar = doc.createElement("div");
120
180
  bar.className = "dg-bar";
121
- bar.append(buttons, say);
181
+ bar.append(row);
122
182
 
123
183
  // 読み上げの道具が段の順に読む一覧。見た目からは外すが、読み上げからは隠さない
124
184
  const list = doc.createElement("ol");
@@ -142,8 +202,16 @@ export function mountDiagram(host: HTMLElement, diagram: unknown, options: Rende
142
202
  host.append(root);
143
203
 
144
204
  const frameWidth = (): number => frame.clientWidth || host.clientWidth || rendered.layout.width;
145
- let mode = chooseView(rendered, frameWidth()).mode;
205
+ /** 高さに収めると頼まれた時だけ、図の枠の中で見える高さ (上下の余白を除く)。測れない時は `undefined` で、幅だけで決める */
206
+ const frameHeight = (): number | undefined => {
207
+ if (!options.fitHeight || !win) return undefined;
208
+ const style = win.getComputedStyle(frame);
209
+ const inner = frame.clientHeight - (Number.parseFloat(style.paddingTop) || 0) - (Number.parseFloat(style.paddingBottom) || 0);
210
+ return inner > 0 ? inner : undefined;
211
+ };
212
+ let mode = chooseView(rendered, frameWidth(), frameHeight()).mode;
146
213
  let width = frameWidth();
214
+ let height = frameHeight();
147
215
  let svg: SVGSVGElement | null = null;
148
216
  let step = 0;
149
217
  let playing = !reduce;
@@ -172,7 +240,7 @@ export function mountDiagram(host: HTMLElement, diagram: unknown, options: Rende
172
240
  svg = next;
173
241
  born = Date.now();
174
242
  };
175
- const markup = (): string => (mode === "follow" ? drawSvg(rendered.diagram, rendered.layout, timeline, palette, rendered.theme, { width, lang }) : rendered.svg);
243
+ const markup = (): string => (mode === "follow" ? drawSvg(rendered.diagram, rendered.layout, timeline, palette, rendered.theme, { width, ...(height === undefined ? {} : { height }), lang }) : rendered.svg);
176
244
 
177
245
  const now = (): number => (playing ? (at + (Date.now() - born) / 1000) % timeline.total : at);
178
246
  const stepAt = (t: number): number => {
@@ -189,6 +257,7 @@ export function mountDiagram(host: HTMLElement, diagram: unknown, options: Rende
189
257
  body.textContent = current?.body ?? "";
190
258
  toggleButton.setAttribute("aria-label", playing ? words.pause : words.play);
191
259
  toggleButton.textContent = playing ? BUTTON_TEXT.pause : BUTTON_TEXT.play;
260
+ options.onStep?.(step);
192
261
  };
193
262
  const restart = (t: number, play: boolean): void => {
194
263
  at = t;
@@ -227,16 +296,18 @@ export function mountDiagram(host: HTMLElement, diagram: unknown, options: Rende
227
296
  toggleButton.addEventListener("click", onToggle);
228
297
  frame.addEventListener("click", onFigure);
229
298
 
230
- // 枠の幅が変わったら見せ方を選び直す。寄って追う窓は幅で決まるので、幅が変われば描き直す
299
+ // 枠の大きさが変わったら見せ方を選び直す。寄って追う窓は幅 (高さに収める時は高さも) で決まるので、変われば描き直す
231
300
  const Observer = (win as (Window & { ResizeObserver?: typeof ResizeObserver }) | null)?.ResizeObserver;
232
301
  const observer = Observer
233
302
  ? new Observer(() => {
234
303
  const w = frameWidth();
235
- const chosen = chooseView(rendered, w).mode;
236
- if (chosen === mode && (mode === "fixed" || w === width)) return;
304
+ const h = frameHeight();
305
+ const chosen = chooseView(rendered, w, h).mode;
306
+ if (chosen === mode && (mode === "fixed" || (w === width && h === height))) return;
237
307
  const t = now();
238
308
  mode = chosen;
239
309
  width = w;
310
+ height = h;
240
311
  at = t;
241
312
  place(markup());
242
313
  })
package/src/printer.ts CHANGED
@@ -1,76 +1,106 @@
1
1
  /**
2
- * 図の原本の JSON を、人が書く記法へ書き戻す。
2
+ * 図の原本の JSON を、人が書く dragon記法へ書き戻す (#3137)。
3
3
  *
4
4
  * 並べ方は決まった形に整える。JSON の欄の並び順は見ず、欄の名前で読むので、
5
5
  * 同じ内容の JSON からは毎回同じ文字列になる。書き手の行の並べ方とコメントは保たない。
6
6
  * 段の中で起きることは JSON の並び順 (起きる順) のまま書く。
7
7
  *
8
- * 語は渡した言葉で書く (#2997)。渡さなければ日本語で書く。
9
- * 英語では添え書きと下の字と書き換えの字を `"…"` で囲み、名前か字に `"` を含む物だけ「」で囲む。
10
- * 英語の語の file でも「」は読めるので、読み直すと同じ JSON に戻る。
8
+ * `"` で囲むのは要る時だけにする。`[` を含む名前と、`,` か `]` を含む属性の値と、`"` で始まる字を囲む。
9
+ * 箱は JSON の並びのまま書き、同じ区画の箱が続く所を 1 つの `group … end` にする。
10
+ * 区画の箱が飛び飛びの時は同じ `group` を開き直す。区画の並びが箱の現れる順と違う時と、
11
+ * 箱の無い区画がある時は、先に全ての区画を中の無い `group … end` で宣言してから開き直す。
11
12
  */
12
- import type { Box, Carry, Diagram, DiagramEvent, Region } from "./schema";
13
- import { type NotationLang, type WORDS, wordOf } from "./words";
13
+ import type { Box, Diagram, DiagramEvent, Region } from "./schema";
14
14
 
15
- type AttrKey = keyof typeof WORDS.attr;
15
+ const quote = (text: string): string => `"${text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
16
+ /** 行の終わりか `[` の前に置く名前と文 */
17
+ const name = (text: string): string => (text.includes("[") || text.startsWith('"') ? quote(text) : text);
18
+ /** `:` の前に置く字 (帯の題と、`set` の欄) */
19
+ const key = (text: string): string => (text.includes(":") || text.includes("[") || text.startsWith('"') ? quote(text) : text);
20
+ /** `strip` の行に `,` で並べる欄 */
21
+ const field = (text: string): string => (text.includes(",") || text.startsWith('"') ? quote(text) : text);
22
+ /** 属性の値 */
23
+ const value = (text: string): string => (text.includes(",") || text.includes("]") || text.startsWith('"') ? quote(text) : text);
16
24
 
17
- export interface PrintOptions {
18
- /** 記法の語の言葉。既定は日本語 */
19
- lang?: NotationLang;
20
- }
21
-
22
- /** 名前の後ろに `text` を囲んで付ける。`text` が無ければ何も付けない */
23
- function quoted(name: string, text: string | undefined, lang: NotationLang): string {
24
- if (text === undefined) return "";
25
- if (lang === "en" && !name.includes('"') && !text.includes('"')) return ` "${text}"`;
26
- return `「${text}」`;
27
- }
25
+ /** 属性の並び。値の無い鍵は書かず、1 つも無ければ何も付けない */
26
+ const attrs = (pairs: [string, string | undefined][]): string => {
27
+ const written = pairs.filter((pair): pair is [string, string] => pair[1] !== undefined);
28
+ return written.length === 0 ? "" : ` [${written.map(([k, v]) => `${k}: ${value(v)}`).join(", ")}]`;
29
+ };
28
30
 
29
- export function printNotation(diagram: Diagram, options: PrintOptions = {}): string {
30
- const lang = options.lang ?? "ja";
31
- const attrs = (pairs: [AttrKey, string | undefined][]): string =>
32
- pairs
33
- .filter((pair): pair is [AttrKey, string] => pair[1] !== undefined)
34
- .map(([key, value]) => ` [${wordOf("attr", key, lang)} ${value}]`)
35
- .join("");
31
+ /** 名札と違う名前だけを `as` の後ろに書く */
32
+ const shown = (id: string, text: string): string => (text === id ? "" : ` as ${name(text)}`);
36
33
 
37
- const region = (r: Region): string =>
38
- `${wordOf("head", "region", lang)} ${r.id} = ${r.name}${quoted(r.name, r.note, lang)}${attrs([["color", r.color], ["icon", r.icon]])}`;
39
- const box = (b: Box): string =>
40
- `${b.id} = ${b.name}${quoted(b.name, b.sub, lang)}${attrs([["color", b.color], ["icon", b.icon], ["region", b.region]])}`;
34
+ const declareRegion = (r: Region): string =>
35
+ `group ${r.id}${shown(r.id, r.name)}${attrs([
36
+ ["note", r.note],
37
+ ["color", r.color],
38
+ ["icon", r.icon],
39
+ ])}`;
40
+ const declareBox = (b: Box): string =>
41
+ `box ${b.id}${shown(b.id, b.name)}${attrs([
42
+ ["note", b.sub],
43
+ ["color", b.color],
44
+ ["icon", b.icon],
45
+ ])}`;
41
46
 
42
- const carry = (c: Carry): string => {
43
- if (c.kind === "envelope") return "";
44
- if (c.kind === "tag") return ` [${wordOf("carry", "tag", lang)} ${c.text}]`;
45
- return ` [${wordOf("carry", c.kind, lang)}]`;
46
- };
47
+ function event(e: DiagramEvent): string {
48
+ switch (e.type) {
49
+ case "edge": {
50
+ const carry: [string, string][] = e.carry.kind === "tag" ? [["tag", e.carry.text]] : e.carry.kind === "envelope" ? [] : [["carry", e.carry.kind]];
51
+ return `${e.from} -> ${e.to}${e.label === undefined ? "" : `: ${name(e.label)}`}${attrs([...carry, ["side", e.labelSide]])}`;
52
+ }
53
+ case "action":
54
+ return `act ${e.box}: ${e.label}`;
55
+ case "swap":
56
+ return `note ${e.box}: ${e.text}`;
57
+ case "strip":
58
+ return `set ${key(e.field)}${e.value === undefined ? "" : `: ${name(e.value)}`}${attrs([["tag", e.tag]])}`;
59
+ }
60
+ }
47
61
 
48
- const event = (e: DiagramEvent): string => {
49
- switch (e.type) {
50
- case "edge":
51
- return `${e.from} -> ${e.to}${e.label === undefined ? "" : ` : ${e.label}`}${carry(e.carry)}${e.labelSide ? ` [${wordOf("attr", "label", lang)} ${wordOf("side", e.labelSide, lang)}]` : ""}`;
52
- case "action":
53
- return `${e.box} : ${e.label}`;
54
- case "swap":
55
- return `${e.box}${quoted("", e.text, lang)}`;
56
- case "strip":
57
- return `${wordOf("head", "strip", lang)} ${e.field}${e.value === undefined ? "" : ` = ${e.value}`}${e.tag === undefined ? "" : ` [${e.tag}]`}`;
62
+ /** 区画と箱の宣言の塊。塊どうしは空行で区切る */
63
+ function declarations(diagram: Diagram): string[] {
64
+ const regions = diagram.regions ?? [];
65
+ const runs: { region?: string; boxes: Box[] }[] = [];
66
+ for (const b of diagram.boxes) {
67
+ const last = runs.at(-1);
68
+ if (last && last.region === b.region) last.boxes.push(b);
69
+ else runs.push({ ...(b.region !== undefined ? { region: b.region } : {}), boxes: [b] });
70
+ }
71
+ const opened = [...new Set(runs.flatMap((run) => (run.region === undefined ? [] : [run.region])))];
72
+ const inOrder = opened.length === regions.length && opened.every((id, i) => regions[i]?.id === id);
73
+ const declared = new Set<string>();
74
+ const blocks: string[] = [];
75
+ if (!inOrder) {
76
+ for (const r of regions) {
77
+ blocks.push(`${declareRegion(r)}\nend`);
78
+ declared.add(r.id);
79
+ }
80
+ }
81
+ for (const run of runs) {
82
+ const lines = run.boxes.map(declareBox);
83
+ if (run.region === undefined) {
84
+ blocks.push(lines.join("\n"));
85
+ continue;
58
86
  }
59
- };
87
+ const region = regions.find((r) => r.id === run.region);
88
+ const open = declared.has(run.region) || !region ? `group ${run.region}` : declareRegion(region);
89
+ declared.add(run.region);
90
+ blocks.push([open, ...lines.map((line) => ` ${line}`), "end"].join("\n"));
91
+ }
92
+ return blocks;
93
+ }
60
94
 
61
- const head = [
62
- ...(diagram.view ? [`${wordOf("head", "view", lang)} ${wordOf("view", diagram.view, lang)}`] : []),
63
- ...(diagram.regions ?? []).map(region),
64
- ...diagram.boxes.map(box),
65
- ...(diagram.strip ? [`${wordOf("head", "strip", lang)} ${diagram.strip.title} = ${diagram.strip.fields.join(", ")}`] : []),
66
- ];
67
- const steps = diagram.steps.map((step) =>
68
- [
69
- `# ${step.title}`,
70
- ...step.body.split("\n").map((line) => `> ${line}`),
71
- ...(step.code === undefined ? [] : [`\`${step.code}\``]),
72
- ...step.events.map(event),
73
- ].join("\n"),
74
- );
75
- return [`${wordOf("kind", diagram.kind, lang)} ${diagram.title}`, head.join("\n"), ...steps].join("\n\n") + "\n";
95
+ export function printNotation(diagram: Diagram): string {
96
+ const blocks = [`${diagram.kind} ${name(diagram.title)}${attrs([["view", diagram.view]])}`, ...declarations(diagram)];
97
+ if (diagram.strip) blocks.push(`strip ${key(diagram.strip.title)}: ${diagram.strip.fields.map(field).join(", ")}`);
98
+ for (const step of diagram.steps) {
99
+ const body = step.body.split("\n");
100
+ const say = body.length === 1 ? [` say ${step.body}`] : [" say", ...body.map((line) => (line === "" ? "" : ` ${line}`))];
101
+ blocks.push(
102
+ [`step ${step.title}`, ...say, ...(step.code === undefined ? [] : [` code ${step.code}`]), ...step.events.map((e) => ` ${event(e)}`)].join("\n"),
103
+ );
104
+ }
105
+ return `${blocks.join("\n\n")}\n`;
76
106
  }
@@ -76,31 +76,51 @@ export function band(region: SceneRegion): string {
76
76
  /** 箱の下の小さな字の移り変わり。`[時刻, 字, class]` の並びで、最初は元の字。 */
77
77
  export type SubChange = [number, string, string];
78
78
 
79
- /** 箱。動く段は縁に色を付け、外側に淡い光を足す。下の小さな字は起きたことに合わせて書き換わる。 */
80
- export function card(box: SceneBox, ctx: Ctx, on: number[], changes: SubChange[]): string {
81
- const { m, sh } = ctx;
82
- const { radius, icon, iconRadius, iconScale, iconY, nameY, subY } = LOOK.box;
79
+ /** 箱の下の小さな字。基準線を `y` に置き、起きたことに合わせて書き換わる。 */
80
+ function subLines(box: SceneBox, m: Motion, changes: SubChange[], y: number): string {
83
81
  const seq: SubChange[] = [[0, box.sub ?? "", "dg-ss"], ...changes];
84
- const subs = seq
82
+ return seq
85
83
  .map(([t, text, cls], k) => {
86
84
  const next = seq[k + 1]?.[0];
87
85
  const anim = k === 0 ? (next === undefined ? "" : m.until(next - MOTION.untilFade)) : next === undefined ? m.rise(t) : m.rise(t, next - MOTION.untilFade);
88
86
  if (text === "") return "";
89
- return `<g${anim ? ` class="dg-a" style="${anim}"` : ""}><text class="${cls}" x="${r1(box.cx)}" y="${r1(box.cy + subY)}" text-anchor="middle">${esc(text)}</text></g>`;
87
+ return `<g${anim ? ` class="dg-a" style="${anim}"` : ""}><text class="${cls}" x="${r1(box.cx)}" y="${r1(y)}" text-anchor="middle">${esc(text)}</text></g>`;
90
88
  })
91
89
  .join("");
90
+ }
91
+
92
+ /** 箱の地と縁。上端 `y` から高さ `h`。動く段 (`act` の間) は縁に色を付け、外側に淡い光を足す。 */
93
+ function cardFrame(box: SceneBox, ctx: Ctx, act: string | undefined, y: number, h: number): string {
94
+ const rect = (cls: string, extra = ""): string => `<rect class="${cls}" x="${r1(box.x)}" y="${r1(y)}" width="${r1(box.w)}" height="${h}" rx="${LOOK.box.radius}"${extra}/>`;
95
+ 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
+ }
97
+
98
+ /** 箱。動く段は縁に色を付け、外側に淡い光を足す。下の小さな字は起きたことに合わせて書き換わる。 */
99
+ export function card(box: SceneBox, ctx: Ctx, on: number[], changes: SubChange[]): string {
100
+ const { icon, iconRadius, iconScale, iconY, nameY, subY } = LOOK.box;
101
+ const subs = subLines(box, ctx.m, changes, box.cy + subY);
92
102
  const act = on.length > 0 ? activeKf(ctx, on) : undefined;
93
- const rect = (cls: string, extra = ""): string => `<rect class="${cls}" x="${r1(box.x)}" y="${r1(box.y)}" width="${r1(box.w)}" height="${box.h}" rx="${radius}"${extra}/>`;
94
103
  const mark =
95
104
  box.icon && ICONS[box.icon]
96
105
  ? `<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)}`
97
106
  : "";
98
107
  return (
99
- `<g style="${cs(box.color)}">${act ? `<g class="dg-a" style="${act}">${rect("dg-halo")}</g>` : ""}${rect("dg-st", ` ${sh}`)}${act ? `<g class="dg-a" style="${act}">${rect("dg-on")}</g>` : ""}` +
108
+ `<g style="${cs(box.color)}">${cardFrame(box, ctx, act, box.y, box.h)}` +
100
109
  `${mark}<text class="dg-sn" x="${r1(box.cx)}" y="${r1(box.cy + nameY)}" text-anchor="middle">${esc(box.name)}</text>${subs}</g>`
101
110
  );
102
111
  }
103
112
 
113
+ /**
114
+ * 寄って追う時系列図の窓の上端に貼る頭の箱 (#3120)。図の頭の箱と同じ横の位置と縁で、アイコンを外して名前と下の字の 2 段にする。
115
+ * 携帯の幅では窓が低く、図の頭の箱のままでは窓の高さの 3 割を頭の列が取るため。
116
+ */
117
+ export function headCard(box: SceneBox, ctx: Ctx, on: number[], changes: SubChange[]): string {
118
+ const { pad, h, nameY, subY } = LOOK.follow.head;
119
+ const subs = subLines(box, ctx.m, changes, pad + subY);
120
+ const act = on.length > 0 ? activeKf(ctx, on) : undefined;
121
+ 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
+ }
123
+
104
124
  export function lifeline(x: number, y1: number, y2: number): string {
105
125
  return `<line class="dg-ll" x1="${r1(x)}" y1="${r1(y1)}" x2="${r1(x)}" y2="${r1(y2)}"/>`;
106
126
  }