@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/render/svg.ts CHANGED
@@ -16,7 +16,7 @@ import { readDiagram } from "../version";
16
16
  import type { Diagram, ViewMode } from "../schema";
17
17
  import { buildTimeline, type Span, type Timeline } from "../timeline";
18
18
  import type { NotationLang } from "../words";
19
- import { action, badge, band, card, carrier, type Ctx, cs, edge, esc, type FieldChange, label, lifeline, strip, type SubChange } from "./parts";
19
+ import { action, badge, band, card, carrier, type Ctx, cs, edge, esc, type FieldChange, headCard, label, lifeline, strip, type SubChange } from "./parts";
20
20
  import { Motion } from "./motion";
21
21
 
22
22
  export interface RenderOptions {
@@ -96,6 +96,7 @@ const PART_CSS = [
96
96
  `.dg-tg text{${font(TYPE.tag)};fill:var(--on-c)}`,
97
97
  ".dg-vp-bg{fill:var(--bg)}",
98
98
  ".dg-vp-frame{fill:none;stroke:var(--hair)}",
99
+ ".dg-hd-bg{fill:var(--bg)}",
99
100
  ".dg-mm-bg{fill:var(--surf);stroke:var(--hair)}",
100
101
  `.dg-mm-t{${font(TYPE.miniTitle)};fill:var(--mute)}`,
101
102
  ".dg-mm-world{fill:var(--sunk);stroke:var(--hair)}",
@@ -189,6 +190,8 @@ function fieldChanges(diagram: Diagram, timeline: Timeline, layout: Layout): Map
189
190
  export interface FollowFrame {
190
191
  /** 枠の幅 (px)。寄って追う窓はこの幅で、字を原寸で見せる */
191
192
  width: number;
193
+ /** 枠の中で見える高さ (px)。渡すと、窓と全体図をこの高さに収め (#3047)、窓をこの高さまで伸ばす (#3053)。枠の高さが図で伸びる置き場では渡さない */
194
+ height?: number;
192
195
  /** 隅の全体図の札の言葉。渡し忘れを型の検査で止めるため、省けない欄にする */
193
196
  lang: NotationLang;
194
197
  }
@@ -233,7 +236,9 @@ export function drawSvg(diagram: Diagram, layout: Layout, timeline: Timeline, pa
233
236
 
234
237
  const { near, far, nearY, farY } = LOOK.shadow;
235
238
  const shadow = `<filter id="dg-sh-${id}" x="-20%" y="-30%" width="140%" height="180%"><feDropShadow dx="0" dy="${nearY}" stdDeviation="${near}" style="flood-color:var(--sh);flood-opacity:var(--shA)"/><feDropShadow dx="0" dy="${farY}" stdDeviation="${far}" style="flood-color:var(--sh);flood-opacity:var(--shB)"/></filter>`;
236
- const view = follow ? followView(layout, timeline, diagram, m, id, content, follow) : { body: `<rect class="dg-bg" width="${layout.width}" height="${layout.height}" rx="${LOOK.region.radius}"/>${content}`, w: layout.width, h: layout.height };
239
+ // 寄って追う時系列図は、頭の箱を窓の上端に貼る頭の列にも描く (#3118)。頭の列の箱はアイコンを外した 2 段 (#3120)。止めて見せる図では描かない
240
+ const heads = follow && layout.kind === "sequence" ? layout.boxes.map((box) => headCard(box, ctx, on.get(box.id) ?? [], subs.get(box.id) ?? [])).join("") : "";
241
+ const view = follow ? followView(layout, timeline, diagram, m, id, content, follow, heads) : { body: `<rect class="dg-bg" width="${layout.width}" height="${layout.height}" rx="${LOOK.region.radius}"/>${content}`, w: layout.width, h: layout.height };
237
242
  const css = `.dg-${id}{${FONT_VARS};--T:${timeline.total}s;${paletteVars(palette, theme)}}${PART_CSS}${m.frames.join("")}`;
238
243
  return (
239
244
  `<svg xmlns="http://www.w3.org/2000/svg" class="dg dg-${id}" viewBox="0 0 ${Math.ceil(view.w)} ${Math.ceil(view.h)}" width="${Math.ceil(view.w)}" height="${Math.ceil(view.h)}" role="img" aria-label="${esc(diagram.title)}" data-dragon="figure" data-view="${follow ? "follow" : "fixed"}">` +
@@ -241,8 +246,37 @@ export function drawSvg(diagram: Diagram, layout: Layout, timeline: Timeline, pa
241
246
  );
242
247
  }
243
248
 
249
+ /** 四角を全部囲む四角。 */
250
+ function union(rects: Rect[]): Rect {
251
+ const x = Math.min(...rects.map((r) => r.x));
252
+ const y = Math.min(...rects.map((r) => r.y));
253
+ return { x, y, w: Math.max(...rects.map((r) => r.x + r.w)) - x, h: Math.max(...rects.map((r) => r.y + r.h)) - y };
254
+ }
255
+
244
256
  /** 段で動く物が占める所。 */
245
257
  function focusOf(layout: Layout, diagram: Diagram, step: number): Rect | undefined {
258
+ const rects = focusParts(layout, diagram, step);
259
+ return rects.length === 0 ? undefined : union(rects);
260
+ }
261
+
262
+ /**
263
+ * 番号の丸が窓から外れる段で、窓に入れる所 (#3046)。
264
+ * 丸とその周りを起点に、丸に近い動く物から順に、窓 (`vw` × `vh`) に収まる間だけ囲む。収まらない物は飛ばして次を見る
265
+ */
266
+ function badgeFocus(badge: { x: number; y: number }, parts: Rect[], vw: number, vh: number): Rect {
267
+ const room = LOOK.badge.r + LOOK.follow.badgeRoom;
268
+ const { x: bx, y: by } = badge;
269
+ const away = (p: Rect): number => Math.hypot(Math.max(0, p.x - bx, bx - p.x - p.w), Math.max(0, p.y - by, by - p.y - p.h));
270
+ let held: Rect = { x: bx - room, y: by - room, w: room * 2, h: room * 2 };
271
+ for (const p of [...parts].sort((a, b) => away(a) - away(b))) {
272
+ const next = union([held, p]);
273
+ if (next.w <= vw && next.h <= vh) held = next;
274
+ }
275
+ return held;
276
+ }
277
+
278
+ /** 段で動く物を 1 つずつ。 */
279
+ function focusParts(layout: Layout, diagram: Diagram, step: number): Rect[] {
246
280
  const rects: Rect[] = [];
247
281
  for (const e of layout.edges.filter((x) => x.step === step)) {
248
282
  const xs = e.points.map((p) => p[0]);
@@ -258,26 +292,74 @@ function focusOf(layout: Layout, diagram: Diagram, step: number): Rect | undefin
258
292
  if (box && (layout.kind === "architecture" || event.type === "swap")) rects.push(box);
259
293
  }
260
294
  }
261
- if (rects.length === 0) return undefined;
262
- const x = Math.min(...rects.map((r) => r.x));
263
- const y = Math.min(...rects.map((r) => r.y));
264
- return { x, y, w: Math.max(...rects.map((r) => r.x + r.w)) - x, h: Math.max(...rects.map((r) => r.y + r.h)) - y };
295
+ return rects;
265
296
  }
266
297
 
267
- /** 寄って追う窓。段ごとに動く所へ寄り、隅の全体図に今見ている所の枠を同じ動きで出す。 */
268
- function followView(layout: Layout, timeline: Timeline, diagram: Diagram, m: Motion, id: string, content: string, follow: FollowFrame): { body: string; w: number; h: number } {
298
+ /**
299
+ * 寄って追う窓。段ごとに動く所へ寄り、窓の外の全体図に今見ている所の枠を同じ動きで出す。
300
+ * 全体図は窓に重ねると今動く線や箱を覆うので、窓の外に置く (#3042)。
301
+ * 縦長の全体図は窓の右に列を取り、横長の全体図は窓の下に置く。失う窓の幅か図の高さの小さい側を選ぶ。
302
+ * 枠の中で見える高さを渡された時は、窓と全体図がその高さに入るまで窓を低くする (#3047)。
303
+ * 枠が余る時は窓を広げ、図の原寸の幅と、見える高さか図の高さまで使う。全体図は今の大きさのまま置く (#3053)。
304
+ * 枠の幅が `LOOK.follow.narrow` より狭い時は、縦長の全体図を出さずに窓へ枠の幅の全部を渡す (#3055)。
305
+ * 段で動く物を全部囲んだ所の真ん中に寄せると今の段の番号の丸が窓から外れる段では、丸に近い動く物から窓に収まるだけ囲んだ所へ寄せる (#3046)。
306
+ * 時系列図は頭の箱 `heads` を窓の上端の頭の列に貼り、窓が横に動く時だけ一緒に動かす。頭の箱は図の上端に在り、線を追う窓からは外れるため。
307
+ * 窓は頭の列の下の残りの高さで動く物を真ん中に見せる (#3118)。
308
+ * 頭の列の箱はアイコンを外した 2 段にして低く貼り、線は図の頭の箱の下から見せ始める (#3120)。
309
+ */
310
+ function followView(layout: Layout, timeline: Timeline, diagram: Diagram, m: Motion, id: string, content: string, follow: FollowFrame, heads: string): { body: string; w: number; h: number } {
269
311
  const F = LOOK.follow;
270
312
  const M = LOOK.mini;
271
- const vw = Math.min(layout.width, Math.max(1, follow.width));
272
- const vh = Math.min(layout.height, Math.max(F.minH, vw * F.aspect));
313
+ const frameWidth = Math.max(1, follow.width);
314
+ const full = Math.min(layout.width, frameWidth);
315
+ const title = FIGURE_TEXT[follow.lang].overview;
316
+ /** 窓の高さ `vh` の時の全体図の寸法。全体図は窓の高さで縮むので、窓を低くしたら測り直す */
317
+ const sized = (vh: number) => {
318
+ // 全体図の寸法は枠の幅と窓の高さで決め、置き場所に依らない
319
+ const mw = Math.min(M.w, full * 0.4, ((vh * 0.4) / layout.height) * layout.width);
320
+ const k = mw / layout.width;
321
+ const mh = layout.height * k;
322
+ // 地の幅は図の縦横の比で決まるので、縦に長い図では札より狭くなる。その時は地を札の幅まで広げ、図は広げた地の真ん中に置く
323
+ const bw = Math.max(mw, Math.ceil(textWidth(title, TYPE.miniTitle)));
324
+ // 縦横の比は窓の高さに依らないので、低くしても全体図の置き場所は変わらない
325
+ return { vh, mw, k, mh, bw, bgW: bw + M.pad * 2, bgH: mh + M.pad * 2 + M.title, beside: mh > mw };
326
+ };
327
+ const natural = sized(Math.min(layout.height, Math.max(F.minH, full * F.aspect)));
328
+ // 携帯の幅では縦長の全体図の列が枠の幅の 3 割を取り、窓が図の横の 3 割ほどしか見せない。その時は全体図を出さない。
329
+ // 横長の全体図は窓の下に出して幅を取らないので、狭い枠でも出す
330
+ const bare = natural.beside && frameWidth < F.narrow;
331
+ // 窓の右の全体図は窓の高さの中に収まり、窓の下の全体図は窓の高さに足される
332
+ const room = follow.height === undefined ? Number.POSITIVE_INFINITY : natural.beside ? follow.height : follow.height - natural.bgH - M.inset;
333
+ // 見える高さが窓より低ければ窓と全体図を縮め、高ければ全体図はそのままに窓を図の高さまで伸ばす。
334
+ // 全体図も伸ばすと、狭い枠では右の全体図の列が太って窓が狭くなる
335
+ const { vh, mw, k, mh, bw, bgW, bgH, beside } =
336
+ room < natural.vh ? sized(Math.max(F.fitMinH, room)) : { ...natural, vh: Number.isFinite(room) ? Math.min(layout.height, room) : natural.vh };
337
+ // 右の全体図の列は枠の余りに置き、窓は図の原寸の幅まで広げる。枠が図より狭い時は今までどおり枠の幅から列を引く
338
+ const vw = beside && !bare ? Math.min(layout.width, frameWidth - bgW - M.inset) : full;
273
339
  const clampX = (x: number): number => Math.min(layout.width - vw / 2, Math.max(vw / 2, x));
274
- const clampY = (y: number): number => Math.min(layout.height - vh / 2, Math.max(vh / 2, y));
340
+ // 時系列図は窓の上端に頭の列を貼るので、動く物は頭の列の下の残りの高さ `below` に見せる (#3118)。
341
+ // 線は図の頭の箱の下 `top` から見せ始め、図の頭の箱は頭の列の下か窓の上に隠す (#3120)
342
+ const top = heads === "" ? 0 : Math.max(...layout.boxes.map((b) => b.y + b.h)) + F.headRoom;
343
+ const strip = heads === "" ? 0 : F.head.pad * 2 + F.head.h;
344
+ const below = vh - strip;
345
+ /** 頭の列の下の残りの窓の真ん中の高さを、図の頭の箱の下と図の下端で止める */
346
+ const clampY = (y: number): number => Math.min(layout.height - below / 2, Math.max(top + below / 2, y));
347
+ const r = LOOK.badge.r;
275
348
  let prev: [number, number] = [layout.width / 2, layout.height / 2];
276
- const centers = timeline.steps.map((_, s) => {
349
+ // 頭の列の下の残りの窓の真ん中。窓の真ん中は、これより頭の列の高さの半分だけ上に在る
350
+ const middles = timeline.steps.map((_, s) => {
277
351
  const f = focusOf(layout, diagram, s);
278
352
  prev = f ? [f.x + f.w / 2, f.y + f.h / 2] : prev;
353
+ const at: [number, number] = [clampX(prev[0]), clampY(prev[1])];
354
+ // 動く物を全部囲んだ所が窓より大きいと、その端に在る番号の丸が窓から外れる。その段だけ丸の近くへ寄せ直す (#3046)
355
+ const b = layout.badges.find((x) => x.step === s);
356
+ if (!b || (Math.abs(b.x - at[0]) <= vw / 2 - r && Math.abs(b.y - at[1]) <= below / 2 - r)) return at;
357
+ const near = badgeFocus(b, focusParts(layout, diagram, s), vw, below);
358
+ // 動く物の無い次の段は、寄せ直した窓のまま止める
359
+ prev = [near.x + near.w / 2, near.y + near.h / 2];
279
360
  return [clampX(prev[0]), clampY(prev[1])] as [number, number];
280
361
  });
362
+ const centers = middles.map(([x, y]): [number, number] => [x, y - strip / 2]);
281
363
  const c0 = centers[0] as [number, number];
282
364
  const cam: [number, number, number, number?, string?][] = [
283
365
  [0, c0[0], c0[1]],
@@ -293,29 +375,42 @@ function followView(layout: Layout, timeline: Timeline, diagram: Diagram, m: Mot
293
375
  cam.push([timeline.last, cl[0], cl[1], 1, EASE.inOut], [timeline.total, c0[0], c0[1]]);
294
376
  const camKf = m.move(cam.map(([t, cx, cy, s, e]) => [t, vw / 2 - cx, vh / 2 - cy, s, e]));
295
377
 
296
- const mw = Math.min(M.w, vw * 0.4, ((vh * 0.4) / layout.height) * layout.width);
297
- const k = mw / layout.width;
298
- const mh = layout.height * k;
299
- // 地の幅は図の縦横の比で決まるので、縦に長い図では札より狭くなる。その時は地を札の幅まで広げ、図は広げた地の真ん中に置く
300
- const title = FIGURE_TEXT[follow.lang].overview;
301
- const bw = Math.max(mw, Math.ceil(textWidth(title, TYPE.miniTitle)));
302
- const bx = vw - bw - M.inset - M.pad;
378
+ // 右に出す時は窓の上端に揃え、下に出す時は窓の右端から内へ寄せる
379
+ const bgX = beside ? vw + M.inset : vw - bgW - M.inset;
380
+ const bgY = beside ? 0 : vh + M.inset;
381
+ const bx = bgX + M.pad;
303
382
  const mx = bx + (bw - mw) / 2;
304
- const my = M.inset + M.pad + M.title;
305
- const shapes = [
306
- ...layout.regions.map((r) => `<rect class="dg-mm-band" style="${cs(r.color)}" x="${mx + r.x * k}" y="${my + r.y * k}" width="${r.w * k}" height="${r.h * k}" rx="3"/>`),
307
- ...layout.boxes.map((b) => `<rect class="dg-mm-card" style="${cs(b.color)}" x="${mx + b.x * k}" y="${my + b.y * k}" width="${b.w * k}" height="${b.h * k}" rx="1.5"/>`),
308
- ].join("");
309
- const vp = m.move(cam.map(([t, cx, cy, , e]) => [t, mx + (cx - vw / 2) * k, my + (cy - vh / 2) * k, 1, e]));
310
- const mini =
311
- `<rect class="dg-mm-bg" x="${bx - M.pad}" y="${my - M.pad - M.title}" width="${bw + M.pad * 2}" height="${mh + M.pad * 2 + M.title}" rx="${M.radius}"/>` +
312
- `<text class="dg-mm-t" x="${bx}" y="${my - M.pad + 2}">${title}</text>` +
313
- `<g clip-path="url(#dg-mm-${id})"><rect class="dg-mm-world" x="${mx}" y="${my}" width="${mw}" height="${mh}"/>${shapes}<rect class="dg-mm-vp dg-a" width="${vw * k}" height="${vh * k}" rx="2" style="${vp}"/></g>`;
383
+ const my = bgY + M.pad + M.title;
384
+ /**
385
+ * 全体図の地と札と、今見ている所の枠。窓と同じ動きで枠を動かす。
386
+ * 時系列図の枠は頭の列の下の線を見せる所だけを囲む。頭の箱は頭の列に常に出ているため (#3122)
387
+ */
388
+ const overview = (): { clip: string; body: string } => {
389
+ const shapes = [
390
+ ...layout.regions.map((r) => `<rect class="dg-mm-band" style="${cs(r.color)}" x="${mx + r.x * k}" y="${my + r.y * k}" width="${r.w * k}" height="${r.h * k}" rx="3"/>`),
391
+ ...layout.boxes.map((b) => `<rect class="dg-mm-card" style="${cs(b.color)}" x="${mx + b.x * k}" y="${my + b.y * k}" width="${b.w * k}" height="${b.h * k}" rx="1.5"/>`),
392
+ ].join("");
393
+ const vp = m.move(cam.map(([t, cx, cy, , e]) => [t, mx + (cx - vw / 2) * k, my + (cy - vh / 2) * k, 1, e]));
394
+ return {
395
+ clip: `<clipPath id="dg-mm-${id}"><rect x="${mx}" y="${my}" width="${mw}" height="${mh}" rx="4"/></clipPath>`,
396
+ body:
397
+ `<rect class="dg-mm-bg" x="${bgX}" y="${bgY}" width="${bgW}" height="${bgH}" rx="${M.radius}"/>` +
398
+ `<text class="dg-mm-t" x="${bx}" y="${my - M.pad + 2}">${title}</text>` +
399
+ `<g clip-path="url(#dg-mm-${id})"><rect class="dg-mm-world" x="${mx}" y="${my}" width="${mw}" height="${mh}"/>${shapes}<rect class="dg-mm-vp dg-a"${strip === 0 ? "" : ` y="${strip * k}"`} width="${vw * k}" height="${(vh - strip) * k}" rx="2" style="${vp}"/></g>`,
400
+ };
401
+ };
402
+ const mini = bare ? { clip: "", body: "" } : overview();
403
+ // 頭の列は窓の中身の上に重ね、窓と同じ時刻と緩急で横にだけ動かす (#3118)
404
+ const head =
405
+ strip === 0
406
+ ? ""
407
+ : `<g class="dg-hd"><rect class="dg-hd-bg" width="${vw}" height="${strip}"/><line class="dg-sep" x1="0" y1="${strip}" x2="${vw}" y2="${strip}"/>` +
408
+ `<g class="dg-a" style="${m.move(cam.map(([t, cx, , s, e]) => [t, vw / 2 - cx, 0, s, e]))}">${heads}</g></g>`;
314
409
  const body =
315
- `<defs><clipPath id="dg-vp-${id}"><rect width="${vw}" height="${vh}" rx="${LOOK.region.radius}"/></clipPath><clipPath id="dg-mm-${id}"><rect x="${mx}" y="${my}" width="${mw}" height="${mh}" rx="4"/></clipPath></defs>` +
316
- `<g clip-path="url(#dg-vp-${id})"><rect class="dg-vp-bg" width="${vw}" height="${vh}"/><g class="dg-a" style="${camKf}">${content}</g></g>` +
317
- `<rect class="dg-vp-frame" x="0.5" y="0.5" width="${vw - 1}" height="${vh - 1}" rx="${LOOK.region.radius}"/>${mini}`;
318
- return { body, w: vw, h: vh };
410
+ `<defs><clipPath id="dg-vp-${id}"><rect width="${vw}" height="${vh}" rx="${LOOK.region.radius}"/></clipPath>${mini.clip}</defs>` +
411
+ `<g clip-path="url(#dg-vp-${id})"><rect class="dg-vp-bg" width="${vw}" height="${vh}"/><g class="dg-a" style="${camKf}">${content}</g>${head}</g>` +
412
+ `<rect class="dg-vp-frame" x="0.5" y="0.5" width="${vw - 1}" height="${vh - 1}" rx="${LOOK.region.radius}"/>${mini.body}`;
413
+ return { body, w: beside && !bare ? vw + M.inset + bgW : full, h: beside ? vh : bgY + bgH };
319
414
  }
320
415
 
321
416
  /** 原本の JSON から図を描く。原本が形に合わない時は、誤りの場所を添えて断る。 */
package/src/schema.ts CHANGED
@@ -15,7 +15,7 @@ import validate from "./schemas/validate-diagram.js";
15
15
  import type { NotationLang } from "./words";
16
16
 
17
17
  export type Color = "blue" | "amber" | "green" | "violet" | "rose" | "slate" | "coin";
18
- /** 色の役の一覧。記法の `[色 …]` で書ける値と、誤りの文に並べる値 */
18
+ /** 色の役の一覧。記法の `color:` に書ける値と、誤りの文に並べる値 */
19
19
  export const COLORS: readonly Color[] = ["blue", "amber", "green", "violet", "rose", "slate", "coin"];
20
20
 
21
21
  export interface Region {
package/src/tokenize.ts CHANGED
@@ -1,28 +1,31 @@
1
1
  /**
2
2
  * 記法を 1 行ずつに切る。
3
3
  *
4
- * 空行とコメントの行 (`//` で始まる行) は捨てる。行の頭と終わりの空白は意味を持たないので落とし、
4
+ * 空行とコメントの行 (`//` で始まる行) も捨てずに返す。`say` の下に字下げして続けた本文では、
5
+ * 空行も `//` で始まる行も本文の字になるため、捨てるかは読む側が行の場所で決める (#3137)。
6
+ * 行の頭と終わりの空白は落とし、頭の空白の字の数を `indent` に残す。
5
7
  * 誤りを示すために元の行の番号と、残した字が始まる列を持たせる。
6
8
  * 行と列は 1 から数え、列は字の数で数える (UTF-16 の単位では数えない)。
7
9
  */
8
10
  export interface SourceLine {
9
11
  line: number;
10
12
  column: number;
13
+ /** 行の頭の空白の字の数。`say` の続きの行を見分ける */
14
+ indent: number;
11
15
  text: string;
12
16
  }
13
17
 
18
+ /** 空行かコメントの行 */
19
+ export const isBlankOrComment = (source: SourceLine): boolean => source.text === "" || source.text.startsWith("//");
20
+
14
21
  /** `text` の `index` 番目 (UTF-16 の位置) にある字が、行の何列目かを返す。 */
15
22
  export function columnOf(source: SourceLine, index: number): number {
16
23
  return source.column + Array.from(source.text.slice(0, index)).length;
17
24
  }
18
25
 
19
26
  export function tokenize(notation: string): SourceLine[] {
20
- const lines: SourceLine[] = [];
21
- notation.split(/\r?\n/).forEach((raw, index) => {
22
- const text = raw.trim();
23
- if (text === "" || text.startsWith("//")) return;
24
- const lead = raw.length - raw.trimStart().length;
25
- lines.push({ line: index + 1, column: Array.from(raw.slice(0, lead)).length + 1, text });
27
+ return notation.split(/\r?\n/).map((raw, index) => {
28
+ const indent = Array.from(raw.slice(0, raw.length - raw.trimStart().length)).length;
29
+ return { line: index + 1, column: indent + 1, indent, text: raw.trim() };
26
30
  });
27
- return lines;
28
31
  }
package/src/view.ts CHANGED
@@ -3,14 +3,17 @@
3
3
  *
4
4
  * 全体を枠の幅へ収めた時の箱の名前の字が 10.5px 以上なら、全体を止めて中だけ動かす (`fixed`)。
5
5
  * 下回るなら、動く所へ寄って追い、隅の全体図で今どこかを示す (`follow`)。
6
+ * 枠の高さを渡すと、全体を枠の幅と高さの両方へ収めた時の字で選ぶ (#3047)。高さの決まった置き場で、縦に長い図を縮めすぎないため。
6
7
  * 書き手が原本の `view` で決めた時は、字の大きさに関わらずそれに従う。
7
8
  */
8
9
  import { READABLE_PX, TYPE } from "./look/values";
9
10
  import type { Rendered } from "./render/svg";
10
11
  import type { ViewMode } from "./schema";
11
12
 
12
- export function chooseView(rendered: Pick<Rendered, "layout" | "view">, frameWidth: number): { mode: ViewMode; textPx: number } {
13
- const textPx = (Math.max(0, frameWidth) / rendered.layout.width) * TYPE.boxName.size;
13
+ export function chooseView(rendered: Pick<Rendered, "layout" | "view">, frameWidth: number, frameHeight?: number): { mode: ViewMode; textPx: number } {
14
+ const byWidth = Math.max(0, frameWidth) / rendered.layout.width;
15
+ const scale = frameHeight === undefined ? byWidth : Math.min(byWidth, Math.max(0, frameHeight) / rendered.layout.height);
16
+ const textPx = scale * TYPE.boxName.size;
14
17
  if (rendered.view) return { mode: rendered.view, textPx };
15
18
  return { mode: textPx >= READABLE_PX ? "fixed" : "follow", textPx };
16
19
  }
package/src/words.ts CHANGED
@@ -1,79 +1,53 @@
1
1
  /**
2
- * 記法の語。役割ごとに日本語と英語の語を組で持つ (#2997)。
2
+ * dragon記法の語 (#3137)。
3
3
  *
4
- * 英語の語は原本の JSON が使う名前に揃え、記法と JSON で覚える語を 1 組にする。
5
- * 1 つの file の言葉は最初の行の図の種類の語で決まり、読む仕組みと書き戻す仕組みは
6
- * その言葉の語だけをこの表から引く。
4
+ * 行の種類は行の頭の英語の語で決まり、属性は `[<鍵>: <値>]` で書く。
5
+ * 語は英語だけで、原本の JSON が使う名前に揃え、記法と JSON で覚える語を 1 組にする。
7
6
  *
8
- * 型が組の欠けを止める。原本の JSON の値 (図の種類、運ぶ物、見せ方、名前の側) に語が無い時と、
9
- * どちらかの言葉の語が無い役割がある時は、型の検査が落ちる。
7
+ * 言葉の型 (`NotationLang`) は、誤りの文と図の中の字の言葉を選ぶために持つ。
8
+ * 語が英語だけなので、記法の file から言葉は決めず、呼ぶ側が渡す。
9
+ *
10
+ * 型が欠けを止める。原本の JSON の値 (図の種類、運ぶ物、見せ方、名前の側) に語が無い時は、型の検査が落ちる。
10
11
  */
11
12
  import type { Carry, Diagram, LabelSide, ViewMode } from "./schema";
12
13
 
13
14
  export type NotationLang = "ja" | "en";
14
15
 
15
- type Pair = Readonly<Record<NotationLang, string>>;
16
-
17
- export const WORDS = {
18
- /** 最初の行の図の種類 */
19
- kind: { sequence: { ja: "時系列図", en: "sequence" }, architecture: { ja: "構成図", en: "architecture" } } satisfies Record<Diagram["kind"], Pair>,
20
- /** 段の見出しより前の行の頭 */
21
- head: {
22
- region: { ja: "区画", en: "region" },
23
- strip: { ja: "帯", en: "strip" },
24
- view: { ja: "見せ方", en: "view" },
25
- } satisfies Record<string, Pair>,
26
- /** 行の終わりの `[<鍵> <値>]` の鍵 */
27
- attr: {
28
- color: { ja: "色", en: "color" },
29
- icon: { ja: "印", en: "icon" },
30
- region: { ja: "区画", en: "region" },
31
- label: { ja: "名前", en: "label" },
32
- } satisfies Record<string, Pair>,
33
- /** 線の上を走る物 */
34
- carry: {
35
- envelope: { ja: "封筒", en: "envelope" },
36
- tag: { ja: "札", en: "tag" },
37
- coin: { ja: "硬貨", en: "coin" },
38
- none: { ja: "なし", en: "none" },
39
- } satisfies Record<Carry["kind"], Pair>,
40
- /** 見せ方の値 */
41
- view: { fixed: { ja: "止める", en: "fixed" }, follow: { ja: "寄る", en: "follow" } } satisfies Record<ViewMode, Pair>,
42
- /** 線の名前を置く側 */
43
- side: {
44
- above: { ja: "上", en: "above" },
45
- below: { ja: "下", en: "below" },
46
- left: { ja: "左", en: "left" },
47
- right: { ja: "右", en: "right" },
48
- } satisfies Record<LabelSide, Pair>,
49
- } as const;
16
+ const keys = <K extends string>(table: Record<K, true>): K[] => Object.keys(table) as K[];
50
17
 
51
- export type WordGroup = keyof typeof WORDS;
18
+ /** 図の種類。最初の行の頭の語 */
19
+ export const KINDS = keys<Diagram["kind"]>({ sequence: true, architecture: true });
52
20
 
53
- /** `group` の役割の `value` を、`lang` の言葉の語にする */
54
- export function wordOf<G extends WordGroup>(group: G, value: keyof (typeof WORDS)[G], lang: NotationLang): string {
55
- return ((WORDS[group] as Record<string, Pair>)[value as string] as Pair)[lang];
56
- }
21
+ /** 最初の行より後の行の頭の語。線の行だけは箱の名札から始まる */
22
+ export const HEADS = ["group", "end", "box", "strip", "step", "say", "code", "act", "note", "set"] as const;
23
+ type Head = (typeof HEADS)[number];
57
24
 
58
- /** `lang` の言葉の語 `word` が、`group` のどの値を指すか。語でなければ `undefined` */
59
- export function valueOf<G extends WordGroup>(group: G, word: string, lang: NotationLang): keyof (typeof WORDS)[G] | undefined {
60
- const found = Object.entries(WORDS[group] as Record<string, Pair>).find(([, pair]) => pair[lang] === word);
61
- return found?.[0] as keyof (typeof WORDS)[G] | undefined;
62
- }
25
+ /** 箱と区画の名札の後ろに、見せる名前を続ける語 (`box <名札> as <名前>`) */
26
+ export const AS = "as";
63
27
 
64
- export const otherLang = (lang: NotationLang): NotationLang => (lang === "ja" ? "en" : "ja");
28
+ /** 行ごとに書ける属性の鍵 */
29
+ export const ATTRS = {
30
+ diagram: ["view"],
31
+ group: ["note", "color", "icon"],
32
+ box: ["note", "color", "icon"],
33
+ edge: ["carry", "tag", "side"],
34
+ set: ["tag"],
35
+ } as const;
65
36
 
66
- /** 記法の最初の行 (空行とコメントの行を除く) の図の種類の語から、言葉を決める。決まらなければ `undefined` */
67
- export function languageOf(headLine: string): NotationLang | undefined {
68
- const first = headLine.trim().split(/\s+/)[0] ?? "";
69
- return (["ja", "en"] as const).find((lang) => valueOf("kind", first, lang) !== undefined);
70
- }
37
+ /** `carry` に書ける運ぶ物。封筒は書かない時、札は `tag` で書く */
38
+ export const CARRIES = keys<Exclude<Carry["kind"], "envelope" | "tag">>({ coin: true, none: true });
39
+ export const VIEWS = keys<ViewMode>({ fixed: true, follow: true });
40
+ export const SIDES = keys<LabelSide>({ above: true, below: true, left: true, right: true });
71
41
 
72
- /** 記法の言葉。最初の行の図の種類の語で決まり、決まらなければ `undefined` */
73
- export function notationLanguage(notation: string): NotationLang | undefined {
74
- const head = notation
75
- .split(/\r?\n/)
76
- .map((line) => line.trim())
77
- .find((line) => line !== "" && !line.startsWith("//"));
78
- return head === undefined ? undefined : languageOf(head);
79
- }
42
+ /**
43
+ * 今の形の行 (#2997 まで) が、新しい形のどの行に当たるか。誤りの文で新しい行の形を示すために引く。
44
+ * 鍵は今の形の行の頭の語で、記号 (`#` など) で始まる行と、名札の後ろに `=` や「」が来る行は読む側が見分ける。
45
+ */
46
+ export const OLD_HEADS = {
47
+ 時系列図: "sequence",
48
+ 構成図: "architecture",
49
+ 区画: "group",
50
+ 見せ方: "view",
51
+ region: "group",
52
+ view: "view",
53
+ } as const satisfies Record<string, Diagram["kind"] | Head | "view">;