/**
* 区画、箱、線、線の名前、運ぶ物、段の番号、中身の帯を 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("");
}