/** * 区画、箱、線、線の名前、運ぶ物、段の番号、中身の帯を SVG に描く部品。見本帳の `engine.js` の部品を写す。 * * どの部品も、置き場所は場面 (`layout/scene.ts`) から、いつ見えるかは時刻の表 (`timeline.ts`) から読む。 * 色は色の役の変数 (`--c` と `--ct`) だけを渡し、値は色味の表が持つ。 */ import { hasWide, textWidth } from "../layout/measure"; import { along, type Point, pathLength, type SceneAction, type SceneBadge, type SceneBox, type SceneEdge, type SceneEnd, type SceneLabel, type ScenePath, type SceneRegion, type SceneSide, type SceneStrip, } from "../layout/scene"; import { type ColorKey, EASE, LOOK, MOTION, TYPE } from "../look/values"; import type { CarrierTime, EdgeTime, LabelTime, Span, Timeline } from "../timeline"; import { ICONS } from "./icons"; import type { Frame, Motion } from "./motion"; export const esc = (s: string): string => s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); const r1 = (v: number): number => Math.round(v * 100) / 100; /** 色の役を部品へ渡す変数。 */ export const cs = (k: ColorKey): string => (k === "coin" ? "--c:var(--coin);--ct:var(--coint)" : `--c:var(--c-${k});--ct:var(--t-${k})`); export function glyph(name: string | undefined, x: number, y: number, scale: number, cls = "dg-gl"): string { const d = name === undefined ? undefined : ICONS[name]; return d ? `` : ""; } /** * 絵柄を 1 つだけ描いた SVG (#3172)。見本帳の部品「絵」の頁が、使える絵柄を並べる時に使う。 * 線の太さは箱の印と同じ割合 (印の線の太さ × 印の縮み) で、色は置いた所の字の色 (`currentColor`) を使う。知らない名前は `undefined` */ export function iconSvg(name: string): string | undefined { if (!Object.hasOwn(ICONS, name)) return undefined; const width = LOOK.stroke.glyph * LOOK.box.iconScale; return ``; } /** 点を辿る線。曲がる所は共通の値の表の半径 (`LOOK.edge.bend`) で丸め、直角の角を作らない (#3171)。 */ export function pathOf(points: Point[]): string { const pts = points.filter((p, i) => i === 0 || p[0] !== (points[i - 1] as Point)[0] || p[1] !== (points[i - 1] as Point)[1]); const [x0, y0] = pts[0] as Point; let d = `M ${r1(x0)} ${r1(y0)}`; for (let i = 1; i < pts.length - 1; i++) { const [ax, ay] = pts[i - 1] as Point; const [bx, by] = pts[i] as Point; const [cx, cy] = pts[i + 1] as Point; const l1 = Math.hypot(bx - ax, by - ay); const l2 = Math.hypot(cx - bx, cy - by); const a = Math.min(LOOK.edge.bend, l1 / 2, l2 / 2); 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)}`; } if (pts.length > 1) { const [lx, ly] = pts[pts.length - 1] as Point; d += ` L ${r1(lx)} ${r1(ly)}`; } return d; } /** 矢じり。線の最後の 2 点の向きに開く。 */ export function arrowHead(points: Point[]): string { const [ex, ey] = points[points.length - 1] as Point; const [px, py] = points[points.length - 2] as Point; const len = Math.hypot(ex - px, ey - py) || 1; const dx = (ex - px) / len; const dy = (ey - py) / len; const { arrow: a, arrowHalf: h } = LOOK.edge; return `M ${r1(ex - dx * a - dy * h)} ${r1(ey - dy * a + dx * h)} L ${r1(ex)} ${r1(ey)} L ${r1(ex - dx * a + dy * h)} ${r1(ey - dy * a - dx * h)}`; } export interface Ctx { m: Motion; t: Timeline; /** 影の filter を付ける属性 */ sh: string; /** 最後の段の終わり */ end: number; } /** 時刻の範囲 `[[a, b], ...]` の間だけ出る。 */ function rangeKf(m: Motion, ranges: [number, number][]): string { const pts: Frame[] = [[0, { opacity: 0 }]]; for (const [a, b] of ranges) pts.push([a, { opacity: 0 }, EASE.out], [a + MOTION.fade, { opacity: 1 }], [b - 0.1, { opacity: 1 }, EASE.inOut], [b + 0.25, { opacity: 0 }]); pts.push([m.total, { opacity: 0 }]); return m.kf(pts); } /** 箱が動く段の並びを、続いた段は 1 つの範囲にまとめて出す。 */ export function activeKf(ctx: Ctx, on: number[]): string { const ranges: [number, number][] = []; for (let k = 0; k < on.length; ) { let e = k; while (e + 1 < on.length && on[e + 1] === (on[e] as number) + 1) e++; ranges.push([(ctx.t.steps[on[k] as number] as Span).start, (ctx.t.steps[on[e] as number] as Span).end]); k = e + 1; } return rangeKf(ctx.m, ranges); } export function band(region: SceneRegion): string { const { radius, icon, iconRadius, iconScale, nameY, line } = LOOK.region; const { x, y } = region.header; const lines = region.lines.map((t, k) => `${esc(t)}`).join(""); const note = region.note ? `${esc(region.note)}` : ""; const mark = region.icon && ICONS[region.icon] ? `${glyph(region.icon, x + icon / 2, y + icon / 2, iconScale)}` : ""; return `${mark}${lines}${note}`; } /** 箱の下の小さな字の移り変わり。`[時刻, 字, class]` の並びで、最初は元の字。 */ export type SubChange = [number, string, string]; /** 箱の下の小さな字。基準線を `y` に置き、起きたことに合わせて書き換わる。 */ function subLines(box: SceneBox, m: Motion, changes: SubChange[], y: number): string { const seq: SubChange[] = [[0, box.sub ?? "", "dg-ss"], ...changes]; return seq .map(([t, text, cls], k) => { const next = seq[k + 1]?.[0]; const anim = k === 0 ? (next === undefined ? "" : m.until(next - MOTION.untilFade)) : next === undefined ? m.rise(t) : m.rise(t, next - MOTION.untilFade); if (text === "") return ""; return `${esc(text)}`; }) .join(""); } /** 箱の地と縁。上端 `y` から高さ `h`。動く段 (`act` の間) は縁に色を付け、外側に淡い光を足す。 */ function cardFrame(box: SceneBox, ctx: Ctx, act: string | undefined, y: number, h: number): string { const rect = (cls: string, extra = ""): string => ``; return `${act ? `${rect("dg-halo")}` : ""}${rect("dg-st", ` ${ctx.sh}`)}${act ? `${rect("dg-on")}` : ""}`; } /** * 箱。動く段は縁に色を付け、外側に淡い光を足す。下の小さな字は起きたことに合わせて書き換わる。 * `lit` を渡すと、動く段ではなくその間だけ光らせる (状態遷移図の、運ぶ物が乗っている状態)。 */ export function card(box: SceneBox, ctx: Ctx, on: number[], changes: SubChange[], lit?: Span[]): string { const { icon, iconRadius, iconScale, iconY, nameY, subY } = LOOK.box; const subs = subLines(box, ctx.m, changes, box.cy + subY); const act = lit ? (lit.length > 0 ? rangeKf(ctx.m, lit.map((s) => [s.start, s.end])) : undefined) : on.length > 0 ? activeKf(ctx, on) : undefined; const mark = box.icon && ICONS[box.icon] ? `${glyph(box.icon, box.cx, box.cy + iconY, iconScale)}` : ""; return ( `${cardFrame(box, ctx, act, box.y, box.h)}` + `${mark}${esc(box.name)}${subs}` ); } /** * 寄って追う時系列図の窓の上端に貼る頭の箱 (#3120)。図の頭の箱と同じ横の位置と縁で、アイコンを外して名前と下の字の 2 段にする。 * 携帯の幅では窓が低く、図の頭の箱のままでは窓の高さの 3 割を頭の列が取るため。 */ export function headCard(box: SceneBox, ctx: Ctx, on: number[], changes: SubChange[]): string { const { pad, h, nameY, subY } = LOOK.follow.head; const subs = subLines(box, ctx.m, changes, pad + subY); const act = on.length > 0 ? activeKf(ctx, on) : undefined; return `${cardFrame(box, ctx, act, pad, h)}${esc(box.name)}${subs}`; } /** 状態遷移図の、どの段も辿り着かない状態。点線の縁の札で、絵柄を左に、名前と下の字を右に並べる (#3171)。光らず、字も書き換わらない */ export function ghostCard(box: SceneBox): string { const { icon, iconRadius, iconScale, radius } = LOOK.box; const G = LOOK.state.ghost; const ix = box.x + G.pad + icon / 2; const tx = box.x + G.pad + icon + G.gap; const mark = box.icon && ICONS[box.icon] ? `${glyph(box.icon, ix, box.cy, iconScale)}` : ""; const nameY = box.sub ? G.nameY : LOOK.baseline.mark; const sub = box.sub ? `${esc(box.sub)}` : ""; return ( `` + `${mark}${esc(box.name)}${sub}` ); } /** 状態遷移図の、出た状態の右上の済んだ印。`at` に弾んで出て、繰り返す前に消える */ export function doneMark(box: SceneBox, at: number, ctx: Ctx): string { const { r, inset } = LOOK.state.done; const x = box.x + box.w - inset; const y = box.y + inset; return `${glyph("check", x, y, 0.5, "dg-done-ck")}`; } /** 状態遷移図の通らない道。点線で道と向きだけを見せ、運ぶ物を走らせない */ export function path(p: ScenePath, color: ColorKey): string { const label = p.label ? `${esc(p.label.text)}` : ""; return `${label}`; } /** 状態遷移図の脇道の囲み。左上に囲みの名前を、その下に添え書きを 1 行ずつ置く */ export function side(s: SceneSide, color: ColorKey): string { const { pad } = LOOK.state.side; const title = s.y + pad + TYPE.regionName.size; const notes = s.notes.map((note, k) => `${esc(note)}`).join(""); return ( `` + `${esc(s.title)}${notes}` ); } /** 状態遷移図の始まりと終わりの印。始まりは塗った丸、終わりは輪の中の丸で、脇道の中の終わりは点線の輪 */ export function endMark(e: SceneEnd, color: ColorKey): string { const { end } = LOOK.state; const text = e.text === "" ? "" : `${esc(e.text)}`; const x = r1(e.x); const y = r1(e.y); if (e.kind === "start") return `${text}`; if (e.ghost) return `${text}`; return `${text}`; } export function lifeline(x: number, y1: number, y2: number): string { return ``; } /** * 線。点線で道を見せておき、その段で色の線を引いて、段の終わりまで流れる点で向きを見せる。 * 済んだ段は薄く残し、最後の段の後に通った道を全て濃くし直す。 * `past` を立てると (状態遷移図、#3171)、段が終わった後は色の線を消して灰の実線に替え、最後の段まで残す。 */ export function edge(e: SceneEdge, time: EdgeTime, ctx: Ctx, past = false): { base: string; lit: string } { const { m } = ctx; const d = pathOf(e.points); const head = arrowHead(e.points); const { start: a, drawn: b, end } = time; const tail: Frame[] = past ? [ [end, { opacity: 1 }, EASE.inOut], [end + 0.4, { opacity: 0 }], [m.total, { opacity: 0 }], ] : [ [end, { opacity: 1 }, EASE.inOut], [end + 0.4, { opacity: 0.45 }], [ctx.end + 0.3, { opacity: 0.45 }, EASE.inOut], [ctx.end + 1, { opacity: 0.95 }], [m.last, { opacity: 0.95 }, EASE.inOut], [m.total, { opacity: 0 }], ]; const line = m.kf([ [0, { opacity: 0, "stroke-dashoffset": 1 }], [a, { opacity: 0, "stroke-dashoffset": 1 }, EASE.inOut], [a + 0.05, { opacity: 1 }], [b, { "stroke-dashoffset": 0 }], [m.last, { "stroke-dashoffset": 0 }], [m.total, { "stroke-dashoffset": 1 }], ...tail, ]); const hk = m.kf([[0, { opacity: 0 }], [b - 0.05, { opacity: 0 }], [b, { opacity: 1 }], ...tail]); const run = -(end - b) * 26; const flow = m.kf([ [0, { opacity: 0, "stroke-dashoffset": 0 }], [b, { opacity: 0, "stroke-dashoffset": 0 }], [b + 0.25, { opacity: 0.9 }], [end - 0.1, { opacity: 0.9 }, EASE.inOut], [end + 0.2, { opacity: 0, "stroke-dashoffset": run }], [m.total, { opacity: 0, "stroke-dashoffset": run }], ]); 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 }]]) : ""; return { base: `${past ? `` : ""}`, lit: ``, }; } /** 線の名前の札。その段の間だけ出し、段の終わりまでに消え終わる。時刻の表が `after` を持つ時は、その間だけ札を外した灰の字で薄く残す (#3171)。 */ export function label(l: SceneLabel, time: LabelTime, ctx: Ctx): string { const ja = l.family === "sans" ? " dg-capj" : ""; const { radius } = LOOK.label; const x = r1(l.x + l.w / 2); const y = r1(l.y + l.h / 2 + LOOK.baseline.label); const past = time.after ? `${esc(l.text)}` : ""; return `${past}${esc(l.text)}`; } /** 時系列図の箱の中の処理の札。その段の間だけ生存線の上に出す。 */ export function action(a: SceneAction, at: number, until: number, ctx: Ctx): string { return `${esc(a.text)}`; } function carrierShape(e: SceneEdge, sh: string): string { const C = LOOK.carrier; if (e.carry.kind === "coin") return ``; if (e.carry.kind === "tag") { const text = e.carry.text; const ja = hasWide(text); const w = textWidth(text, ja ? TYPE.carrierJa : TYPE.carrier) + C.tagPadX * 2; return `${esc(text)}`; } return ``; } const easeInOut = (t: number): number => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2); /** 運ぶ物。線の上を走り、着いた先 (`settle`) へ寄って、次の同じ主役が出発するまで留まる。 */ export function carrier(e: SceneEdge, time: CarrierTime, ctx: Ctx): string { const { m } = ctx; const n = LOOK.carrier.samples; const length = pathLength(e.points); const { depart: a, arrive: b, start: from, end: gone } = time; const path: [number, number, number][] = []; for (let k = 0; k <= n; k++) { const [x, y] = along(e.points, easeInOut(k / n) * length); path.push([a + ((b - a) * k) / n, x, y]); } const first = path[0] as [number, number, number]; const lastPoint = path[path.length - 1] as [number, number, number]; const rest: Point = e.settle ?? [lastPoint[1], lastPoint[2]]; const pts: [number, number, number][] = [[0, first[1], first[2]], ...path]; if (e.settle) pts.push([Math.min(b + 0.3, Math.max(gone, b + 0.01)), rest[0], rest[1]]); pts.push([gone, rest[0], rest[1]], [m.total, rest[0], rest[1]]); // 次の運ぶ物と入れ替わる時は、重なって 2 つに見えないよう素早く消す const fade = gone < m.last ? 0.05 : 0.18; return `${carrierShape(e, ctx.sh)}`; } /** * 段の番号。まだ来ていない段は出さず、今の段は塗り、済んだ段は縁取りに変えて番号を読めるまま残す。 * `grey` を立てると (状態遷移図、#3171)、済んだ段の縁取りを線と同じ灰にする。 */ export function badge(b: SceneBadge, ctx: Ctx, grey = false): string { const { m } = ctx; const span = ctx.t.steps[b.step] as Span; const a = span.start; const e = span.end; const { r, ring: rr } = LOOK.badge; const fill = m.kf([ [0, { opacity: 0, transform: "scale(.5)" }], [a, { opacity: 0, transform: "scale(.5)" }, EASE.pop], [a + 0.35, { opacity: 1, transform: "scale(1)" }], [e, { opacity: 1, transform: "scale(1)" }, EASE.inOut], [e + 0.4, { opacity: 0, transform: "scale(1)" }], [m.total, { opacity: 0, transform: "scale(.5)" }], ]); const past = m.kf([[0, { opacity: 0 }], [e, { opacity: 0 }, EASE.inOut], [e + 0.4, { opacity: 1 }], [m.last, { opacity: 1 }, EASE.inOut], [m.total, { opacity: 0 }]]); const ring = m.kf([ [0, { opacity: 0, transform: "scale(1)" }], [a + 0.05, { opacity: 0, transform: "scale(1)" }], [a + 0.1, { opacity: 0.7, transform: "scale(1)" }, EASE.out], [a + 0.9, { opacity: 0, transform: "scale(2.3)" }], [m.total, { opacity: 0, transform: "scale(1)" }], ]); const x = r1(b.x); const y = r1(b.y); const ty = r1(b.y + LOOK.baseline.badge); const no = b.step + 1; return ( `` + `${no}` + `${no}` ); } /** 中身の帯の 1 欄の移り変わり。値と札は、次の値か札が来るまで出る。 */ export interface FieldChange { at: number; value?: string; tag?: string; color: ColorKey; /** 変わった段と、その段の終わり */ step: number; until: number; } /** * 中身の帯。`now` を立てると (状態遷移図、#3171)、段の中で変わった値をその段の終わりまで線の色 (今の状態の色) で描き、 * 欄の名前の横に同じ色の点を置く。帯の通った状態の欄は、`arrivals` が持つ着いた時刻に点を塗る。 */ export function strip(s: SceneStrip, changes: Map, ctx: Ctx, now = false, arrivals = new Map()): string { const { m, sh } = ctx; const S = LOOK.strip; const my = s.y + S.h / 2; const parts = [ ``, `${glyph("env", s.x + S.pad + S.icon / 2, my, 0.78)}`, `${esc(s.title)}`, ]; s.fields.forEach((field, i) => { const prev = s.fields[i - 1]; if (prev && prev.y === field.y) { const sx = field.x - S.fieldGap / 2; parts.push(``); } parts.push(`${esc(field.name)}`); const list = changes.get(field.name) ?? []; const values = list.filter((c) => c.value !== undefined); const tags = list.filter((c) => c.tag !== undefined); values.forEach((c, k) => { const next = values[k + 1]?.at; const value = c.value as string; const cls = hasWide(value) ? "dg-fvj" : "dg-fv"; const textOf = (c: string): string => `${esc(value)}`; const text = textOf(cls); if (!now) { const anim = next === undefined ? m.rise(c.at) : m.rise(c.at, next - MOTION.untilFade); parts.push(`${text}`); return; } // 変わった段の間は今の状態の色で、段が終わると墨に戻す const until = Math.min(c.until, next ?? c.until); const dot = ``; parts.push(`${textOf(`${cls} dg-fv-c`)}${dot}`); if (next === undefined || next > until) parts.push(`${text}`); }); tags.forEach((c, k) => { const next = tags[k + 1]?.at; const tag = c.tag as string; // 札は、その時に出ている値の右に置く const shown = [...values].reverse().find((v) => v.at <= c.at + 1e-9)?.value; const x = field.x + (shown ? textWidth(shown, hasWide(shown) ? TYPE.fieldValueJa : TYPE.fieldValue) + S.tagGap : 0); const w = textWidth(tag, TYPE.tag) + S.tagPadX * 2; const y = field.y + S.valueY - S.tagH / 2 - LOOK.baseline.tag; parts.push( `${esc(tag)}`, ); }); }); if (s.trail) { const t = s.trail; const last = s.fields[s.fields.length - 1]; if (last && last.y === t.y) { const sx = t.x - S.fieldGap / 2; parts.push(``); } parts.push(`${esc(t.title)}`); const dy = t.y + S.valueY - TYPE.fieldValue.size / 3; t.states.forEach((state, k) => { const cx = r1(t.x + S.trail.r + k * S.trail.step); const at = arrivals.get(state.box); const fill = at === undefined ? "" : ``; parts.push(`${fill}`); }); } return parts.join(""); }