@litfamily/litopencode 1.0.31 → 1.0.33
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/CHANGELOG.md +38 -0
- package/README-Ko-KR.md +6 -6
- package/README.md +6 -6
- package/TRADEMARK.md +12 -0
- package/dist/activation-primary-prompts.js +5 -1
- package/dist/activation-probe.js +1 -0
- package/dist/activation-prompt-utils.d.ts +1 -1
- package/dist/activation-reentry.d.ts +20 -0
- package/dist/activation-reentry.js +110 -1
- package/dist/activation-routing.d.ts +2 -1
- package/dist/activation-routing.js +100 -11
- package/dist/activation-workflow-prompts.d.ts +4 -0
- package/dist/activation-workflow-prompts.js +30 -0
- package/dist/activation.d.ts +1 -1
- package/dist/activation.js +44 -5
- package/dist/adhd-next.d.ts +51 -0
- package/dist/adhd-next.js +150 -0
- package/dist/agents/defaults.js +1 -1
- package/dist/cli/managed-skill-assets.d.ts +1 -1
- package/dist/cli/managed-skill-assets.js +7 -1
- package/dist/cli/native-skill-integrity.js +1 -1
- package/dist/cli.js +7 -1
- package/dist/commands.d.ts +28 -1
- package/dist/commands.js +58 -5
- package/dist/durable-plan.d.ts +5 -1
- package/dist/durable-plan.js +14 -2
- package/dist/features.d.ts +80 -1
- package/dist/features.js +105 -1
- package/dist/hooks.js +3 -0
- package/dist/ledger.js +12 -1
- package/dist/plan-scope-roots.d.ts +23 -0
- package/dist/plan-scope-roots.js +116 -0
- package/dist/restart-notice.d.ts +7 -0
- package/dist/restart-notice.js +40 -0
- package/dist/secret-shapes.js +5 -5
- package/dist/server.js +4 -0
- package/dist/session-liveness.d.ts +20 -0
- package/dist/session-liveness.js +99 -0
- package/dist/skills.d.ts +22 -1
- package/dist/skills.js +36 -0
- package/dist/start-work-typed.d.ts +34 -0
- package/dist/start-work-typed.js +167 -0
- package/dist/state.d.ts +3 -0
- package/dist/state.js +3 -0
- package/dist/tools.js +22 -2
- package/dist/tui/mission-band.d.ts +22 -1
- package/dist/tui/mission-band.js +75 -16
- package/dist/tui/mission-looks.d.ts +38 -0
- package/dist/tui/mission-looks.js +73 -0
- package/dist/tui/mission-model.d.ts +2 -0
- package/dist/tui/mission-model.js +3 -1
- package/dist/tui/mission-rainbow.d.ts +33 -0
- package/dist/tui/mission-rainbow.js +90 -0
- package/dist/tui/mission-source.d.ts +49 -0
- package/dist/tui/mission-source.js +217 -2
- package/dist/tui/mission-text.d.ts +2 -0
- package/dist/tui/mission-text.js +9 -1
- package/dist/tui/mission-threads.d.ts +58 -0
- package/dist/tui/mission-threads.js +128 -0
- package/dist/tui.js +112 -7
- package/dist/uiux-visual-catalog.js +8 -1
- package/dist/v2/events.js +3 -0
- package/dist/v2/session-hooks.js +38 -5
- package/dist/workflow-stack.d.ts +53 -0
- package/dist/workflow-stack.js +196 -0
- package/docs/assets/readme/badge-version.svg +1 -1
- package/docs/assets/skills/lit-schematic.webp +0 -0
- package/docs/reference-Ko-KR.md +48 -3
- package/docs/reference.md +54 -3
- package/package.json +1 -1
- package/skills/frontend-ui-ux/SKILL.md +3 -2
- package/skills/frontend-ui-ux/kit/components.css +414 -0
- package/skills/frontend-ui-ux/kit/fonts.json +22 -0
- package/skills/frontend-ui-ux/kit/layouts/landing.html +164 -0
- package/skills/frontend-ui-ux/kit/layouts/settings.html +114 -0
- package/skills/frontend-ui-ux/kit/layouts/workspace.html +115 -0
- package/skills/frontend-ui-ux/kit/styles/computational-brutalism.json +227 -0
- package/skills/frontend-ui-ux/kit/styles/field-notes.json +205 -0
- package/skills/frontend-ui-ux/kit/styles/graphite.json +200 -0
- package/skills/frontend-ui-ux/kit/styles/meadow.json +199 -0
- package/skills/frontend-ui-ux/kit/styles/neo-brutalism.json +222 -0
- package/skills/frontend-ui-ux/kit/ui.js +226 -0
- package/skills/frontend-ui-ux/references/brand-and-imagery.md +2 -2
- package/skills/frontend-ui-ux/references/complete-contract.md +2 -1
- package/skills/frontend-ui-ux/references/craft-floor.md +6 -2
- package/skills/frontend-ui-ux/references/default-editorial-pixel.json +3 -0
- package/skills/frontend-ui-ux/references/house-styles.md +236 -0
- package/skills/frontend-ui-ux/references/motion-guide.md +2 -2
- package/skills/frontend-ui-ux/references/production.md +3 -1
- package/skills/frontend-ui-ux/references/slop-register.md +4 -4
- package/skills/frontend-ui-ux/scripts/design-lock.mjs +400 -0
- package/skills/frontend-ui-ux/scripts/pixel-art.mjs +60 -0
- package/skills/frontend-ui-ux/scripts/probe-lock.mjs +235 -0
- package/skills/frontend-ui-ux/scripts/probe-page.mjs +34 -12
- package/skills/frontend-ui-ux/scripts/probe-static.mjs +16 -3
- package/skills/frontend-ui-ux/scripts/probe-thresholds.json +8 -2
- package/skills/frontend-ui-ux/scripts/probe.mjs +35 -7
- package/skills/lit-adhd/SKILL.md +209 -0
- package/skills/lit-adhd/references/brief-format.md +46 -0
- package/skills/lit-adhd/references/card-examples.md +103 -0
- package/skills/lit-adhd/references/status-taxonomy.md +34 -0
- package/skills/lit-adhd/scripts/rewind.mjs +933 -0
- package/skills/lit-diagram-drawer/SKILL.md +4 -3
- package/skills/lit-diagram-drawer/references/style-guide.md +20 -11
- package/skills/lit-diagram-drawer/references/type-architecture.md +3 -3
- package/skills/lit-diagram-drawer/references/type-dependency.md +1 -1
- package/skills/lit-diagram-drawer/references/type-tree.md +3 -3
- package/skills/lit-diagram-drawer/references/type-wardley.md +1 -1
- package/skills/lit-diagram-drawer/references/verifier-guide.md +1 -0
- package/skills/lit-diagram-drawer/scripts/craft-thresholds.json +1 -2
- package/skills/lit-diagram-drawer/scripts/visual-quality.mjs +1 -2
- package/skills/lit-docx/SKILL.md +2 -1
- package/skills/lit-handoff/SKILL.md +2 -0
- package/skills/lit-hijack/SKILL.md +422 -0
- package/skills/lit-hijack/references/capture-and-provenance.md +87 -0
- package/skills/lit-hijack/references/clean-room-build.md +111 -0
- package/skills/lit-hijack/references/distil-and-licence.md +100 -0
- package/skills/lit-hijack/references/gap-and-port-design.md +126 -0
- package/skills/lit-hijack/references/parity-and-report.md +110 -0
- package/skills/lit-hijack/scripts/hijack.mjs +993 -0
- package/skills/lit-plan/SKILL.md +8 -0
- package/skills/lit-pptx/SKILL.md +27 -11
- package/skills/lit-pptx/fonts/jetbrains-mono/JetBrainsMono-Bold.ttf +0 -0
- package/skills/lit-pptx/fonts/jetbrains-mono/JetBrainsMono-Regular.ttf +0 -0
- package/skills/lit-pptx/fonts/jetbrains-mono/OFL.txt +93 -0
- package/skills/lit-pptx/fonts/jetbrains-mono/SOURCE.md +17 -0
- package/skills/lit-pptx/references/density-and-fill.md +9 -3
- package/skills/lit-pptx/references/direction-step.md +21 -6
- package/skills/lit-pptx/references/examples/04-lecture-chalk-ko.md +3 -6
- package/skills/lit-pptx/references/examples/05-research-talk-paper-en.md +1 -5
- package/skills/lit-pptx/references/examples/08-reliability-keynote-night-en.md +0 -4
- package/skills/lit-pptx/references/examples/09-status-update-ledger-en.md +2 -2
- package/skills/lit-pptx/references/examples/11-forecast-review-night-ko.md +4 -7
- package/skills/lit-pptx/references/examples/13-engineering-review-computational-brutalism-ko.md +173 -0
- package/skills/lit-pptx/references/examples/14-launch-pitch-neo-brutalism-ko.md +154 -0
- package/skills/lit-pptx/references/examples/15-design-lecture-bauhaus-ko.md +139 -0
- package/skills/lit-pptx/references/examples/16-community-programme-riso-ko.md +158 -0
- package/skills/lit-pptx/references/examples/17-public-data-briefing-newsroom-ko.md +179 -0
- package/skills/lit-pptx/references/layout-families.md +65 -47
- package/skills/lit-pptx/references/title-treatments.md +83 -134
- package/skills/lit-pptx/references/tonalities/atlas.md +3 -3
- package/skills/lit-pptx/references/tonalities/bauhaus.md +140 -0
- package/skills/lit-pptx/references/tonalities/chalk.md +7 -6
- package/skills/lit-pptx/references/tonalities/computational-brutalism.md +150 -0
- package/skills/lit-pptx/references/tonalities/gazette.md +1 -1
- package/skills/lit-pptx/references/tonalities/ledger.md +1 -1
- package/skills/lit-pptx/references/tonalities/neo-brutalism.md +161 -0
- package/skills/lit-pptx/references/tonalities/newsroom.md +144 -0
- package/skills/lit-pptx/references/tonalities/night.md +1 -1
- package/skills/lit-pptx/references/tonalities/paper.md +2 -2
- package/skills/lit-pptx/references/tonalities/riso.md +137 -0
- package/skills/lit-pptx/references/tonalities/signal.md +3 -3
- package/skills/lit-pptx/references/tonalities/studio.md +7 -6
- package/skills/lit-pptx/run.mjs +2 -1
- package/skills/lit-pptx/scripts/compile-deck.js +1 -1
- package/skills/lit-pptx/scripts/craft-thresholds.json +4 -8
- package/skills/lit-pptx/scripts/craft_extras.py +56 -3
- package/skills/lit-pptx/scripts/deck_output.py +75 -90
- package/skills/lit-pptx/scripts/embed_fonts.py +13 -1
- package/skills/lit-pptx/scripts/layout_inventory.py +12 -5
- package/skills/lit-pptx/scripts/lib/grid-resolver.js +101 -110
- package/skills/lit-pptx/scripts/lib/layout-resolver.js +9 -15
- package/skills/lit-pptx/scripts/lib/render-pack.js +373 -566
- package/skills/lit-pptx/scripts/lib/slide-grid.js +104 -0
- package/skills/lit-pptx/scripts/lib/template-registry.js +57 -6
- package/skills/lit-pptx/scripts/qa_deck.py +10 -11
- package/skills/lit-pptx/scripts/render_pages.py +118 -0
- package/skills/lit-pptx/templates/tonalities/atlas/pack.yaml +1 -1
- package/skills/lit-pptx/templates/tonalities/bauhaus/pack.yaml +98 -0
- package/skills/lit-pptx/templates/tonalities/chalk/pack.yaml +1 -1
- package/skills/lit-pptx/templates/tonalities/computational-brutalism/pack.yaml +99 -0
- package/skills/lit-pptx/templates/tonalities/gazette/pack.yaml +1 -1
- package/skills/lit-pptx/templates/tonalities/ledger/pack.yaml +1 -1
- package/skills/lit-pptx/templates/tonalities/neo-brutalism/pack.yaml +107 -0
- package/skills/lit-pptx/templates/tonalities/newsroom/pack.yaml +99 -0
- package/skills/lit-pptx/templates/tonalities/night/pack.yaml +1 -1
- package/skills/lit-pptx/templates/tonalities/paper/pack.yaml +1 -1
- package/skills/lit-pptx/templates/tonalities/riso/pack.yaml +99 -0
- package/skills/lit-pptx/templates/tonalities/signal/pack.yaml +2 -2
- package/skills/lit-pptx/templates/tonalities/studio/pack.yaml +1 -1
- package/skills/lit-recap/SKILL.md +4 -0
- package/skills/lit-schematic/SKILL.md +118 -0
- package/skills/lit-schematic/examples/iso-heat-exchanger/build.mjs +38 -0
- package/skills/lit-schematic/examples/iso-heat-exchanger/scene.mjs +133 -0
- package/skills/lit-schematic/examples/journal-adsorption-rig/build.mjs +87 -0
- package/skills/lit-schematic/examples/pid-dosing-skid/build.mjs +85 -0
- package/skills/lit-schematic/references/cutaway-outputs.md +70 -0
- package/skills/lit-schematic/references/isometric-craft.md +101 -0
- package/skills/lit-schematic/references/journal-figures.md +72 -0
- package/skills/lit-schematic/references/kit-api.md +134 -0
- package/skills/lit-schematic/references/pfd-and-layout.md +53 -0
- package/skills/lit-schematic/references/pid-drafting.md +103 -0
- package/skills/lit-schematic/references/pid-symbols.md +163 -0
- package/skills/lit-schematic/references/verification.md +100 -0
- package/skills/lit-schematic/scripts/doctor.mjs +57 -0
- package/skills/lit-schematic/scripts/lib/figure.mjs +81 -0
- package/skills/lit-schematic/scripts/lib/fonts.mjs +25 -0
- package/skills/lit-schematic/scripts/lib/geom.mjs +113 -0
- package/skills/lit-schematic/scripts/lib/interactive.mjs +292 -0
- package/skills/lit-schematic/scripts/lib/isa.mjs +115 -0
- package/skills/lit-schematic/scripts/lib/iso.mjs +719 -0
- package/skills/lit-schematic/scripts/lib/legend-sheet.mjs +162 -0
- package/skills/lit-schematic/scripts/lib/pid.mjs +937 -0
- package/skills/lit-schematic/scripts/lib/png.mjs +132 -0
- package/skills/lit-schematic/scripts/lib/qa-core.mjs +658 -0
- package/skills/lit-schematic/scripts/lib/route.mjs +218 -0
- package/skills/lit-schematic/scripts/lib/sheet.mjs +890 -0
- package/skills/lit-schematic/scripts/lib/svg.mjs +82 -0
- package/skills/lit-schematic/scripts/lib/theme.mjs +115 -0
- package/skills/lit-schematic/scripts/motion.mjs +129 -0
- package/skills/lit-schematic/scripts/qa.mjs +92 -0
- package/skills/lit-schematic/scripts/render.mjs +233 -0
- package/skills/lit-schematic/scripts/scaffold.mjs +60 -0
- package/skills/lit-scientific-visualization/SKILL.md +27 -4
- package/skills/lit-typographic-motion/SKILL.md +19 -17
- package/skills/lit-typographic-motion/THIRD_PARTY_NOTICES +9 -0
- package/skills/lit-typographic-motion/engine/accents.mjs +6 -0
- package/skills/lit-typographic-motion/engine/assets.mjs +282 -0
- package/skills/lit-typographic-motion/engine/blender/runner.py +228 -0
- package/skills/lit-typographic-motion/engine/blender.mjs +126 -0
- package/skills/lit-typographic-motion/engine/board.mjs +138 -0
- package/skills/lit-typographic-motion/engine/cover/assets.mjs +11 -8
- package/skills/lit-typographic-motion/engine/cover/capture.mjs +6 -6
- package/skills/lit-typographic-motion/engine/cover/render.mjs +20 -7
- package/skills/lit-typographic-motion/engine/cover/treatment.mjs +30 -3
- package/skills/lit-typographic-motion/engine/fill.mjs +54 -0
- package/skills/lit-typographic-motion/engine/gallery.mjs +118 -0
- package/skills/lit-typographic-motion/engine/house.mjs +42 -0
- package/skills/lit-typographic-motion/engine/look.mjs +65 -8
- package/skills/lit-typographic-motion/engine/loudness.mjs +101 -0
- package/skills/lit-typographic-motion/engine/scaffold.mjs +219 -0
- package/skills/lit-typographic-motion/engine/score.mjs +594 -0
- package/skills/lit-typographic-motion/engine/sound.mjs +9 -57
- package/skills/lit-typographic-motion/engine/stage/capture.mjs +23 -9
- package/skills/lit-typographic-motion/engine/stage/clock.js +8 -2
- package/skills/lit-typographic-motion/engine/stage/cover-init.js +12 -1
- package/skills/lit-typographic-motion/engine/stage/frame-worker.mjs +15 -7
- package/skills/lit-typographic-motion/engine/stage/gate.mjs +48 -4
- package/skills/lit-typographic-motion/engine/stage/qa-page.js +16 -5
- package/skills/lit-typographic-motion/engine/stage/qa.mjs +148 -7
- package/skills/lit-typographic-motion/engine/stage/render.mjs +69 -7
- package/skills/lit-typographic-motion/engine/stage/scan.mjs +20 -1
- package/skills/lit-typographic-motion/engine/stage/stage-kit.js +85 -4
- package/skills/lit-typographic-motion/engine/stills.mjs +1 -1
- package/skills/lit-typographic-motion/engine/styles.mjs +221 -0
- package/skills/lit-typographic-motion/engine/text.mjs +1 -1
- package/skills/lit-typographic-motion/engine/treatment.mjs +190 -6
- package/skills/lit-typographic-motion/engine/vendor/three/LICENSE +21 -0
- package/skills/lit-typographic-motion/engine/vendor/three/addons/environments/RoomEnvironment.js +144 -0
- package/skills/lit-typographic-motion/engine/vendor/three/addons/geometries/RoundedBoxGeometry.js +155 -0
- package/skills/lit-typographic-motion/engine/vendor/three/three.module.js +54571 -0
- package/skills/lit-typographic-motion/gate.mjs +18 -2
- package/skills/lit-typographic-motion/probe.mjs +13 -1
- package/skills/lit-typographic-motion/recipes/hero-3d/expected.json +64 -0
- package/skills/lit-typographic-motion/recipes/hero-3d/index.html +132 -0
- package/skills/lit-typographic-motion/recipes/orbit-loop/expected.json +2 -0
- package/skills/lit-typographic-motion/recipes/painted-light/expected.json +2 -0
- package/skills/lit-typographic-motion/recipes/weight-tide/expected.json +2 -0
- package/skills/lit-typographic-motion/references/cover.md +56 -13
- package/skills/lit-typographic-motion/references/craft-loop.md +20 -7
- package/skills/lit-typographic-motion/references/depth.md +112 -0
- package/skills/lit-typographic-motion/references/runtime.md +21 -6
- package/skills/lit-typographic-motion/references/sound.md +76 -0
- package/skills/lit-typographic-motion/references/stage.md +50 -7
- package/skills/lit-typographic-motion/references/studio.md +162 -0
- package/skills/lit-typographic-motion/references/style-bibles.md +18 -0
- package/skills/lit-typographic-motion/references/treatment.md +108 -9
- package/skills/lit-typographic-motion/render.mjs +60 -3
- package/skills/lit-typographic-motion/styles/paper.json +185 -0
- package/skills/lit-typographic-motion/styles/swiss-signal.json +188 -0
- package/skills/lit-typographic-motion/styles/terminalcore.json +182 -0
- package/skills/lit-typographic-motion/styles/tidal.json +187 -0
- package/skills/litresearch/SKILL.md +2 -0
- package/skills/managed-skill-manifest.json +517 -88
- package/skills/readme-studio/SKILL.md +1 -1
- package/skills/review-work/SKILL.md +2 -0
- package/skills/start-work/SKILL.md +8 -0
- package/skills/workflow-loop/SKILL.md +4 -0
- package/tools/check-pack-payload.mjs +5 -2
- package/tools/payload-substance-parity.json +4 -3
- package/tools/qa-cover-recipes.mjs +4 -3
- package/tools/version-manifests.json +2 -2
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Reports what lit-schematic can do on this machine. Drawing and QA need only Node; PNG and
|
|
3
|
+
// PDF export need Chrome or Chromium; Korean labels render in Pretendard when it is found; a
|
|
4
|
+
// cutaway's motion loop needs ffmpeg (MP4, GIF) and img2webp (WebP), and is the only part that does.
|
|
5
|
+
// It never installs anything and always exits 0 unless --strict is given and export is blocked.
|
|
6
|
+
//
|
|
7
|
+
// node doctor.mjs [--out <dir>] [--json] [--strict]
|
|
8
|
+
|
|
9
|
+
import { accessSync, constants, existsSync } from "node:fs";
|
|
10
|
+
import { resolve } from "node:path";
|
|
11
|
+
import { pathToFileURL } from "node:url";
|
|
12
|
+
import { findPretendard } from "./lib/fonts.mjs";
|
|
13
|
+
import { findTool } from "./motion.mjs";
|
|
14
|
+
import { findChrome } from "./render.mjs";
|
|
15
|
+
|
|
16
|
+
export function diagnose({ out = process.cwd(), env = process.env } = {}) {
|
|
17
|
+
const major = Number(process.versions.node.split(".")[0]);
|
|
18
|
+
const chrome = findChrome(env);
|
|
19
|
+
const font = findPretendard();
|
|
20
|
+
let writable = false;
|
|
21
|
+
try {
|
|
22
|
+
accessSync(existsSync(out) ? out : resolve(out, ".."), constants.W_OK);
|
|
23
|
+
writable = true;
|
|
24
|
+
} catch {
|
|
25
|
+
writable = false;
|
|
26
|
+
}
|
|
27
|
+
const checks = [
|
|
28
|
+
{ id: "node", ok: major >= 20, detail: `Node ${process.versions.node}`, needed: "drawing, QA" },
|
|
29
|
+
{ id: "renderer", ok: Boolean(chrome), detail: chrome ?? "no Chrome or Chromium found (set CHROME_PATH)", needed: "PNG, PDF, close-up sheets" },
|
|
30
|
+
{ id: "font.korean", ok: Boolean(font), detail: font ? `${font.file} (${font.source})` : "Pretendard not found; Hangul falls back to the system font", needed: "Korean labels" },
|
|
31
|
+
{ id: "output", ok: writable, detail: resolve(out), needed: "writing files" },
|
|
32
|
+
{ id: "motion.ffmpeg", ok: Boolean(findTool("ffmpeg", env)), detail: findTool("ffmpeg", env) ?? "ffmpeg not found", needed: "motion loop (MP4, GIF)" },
|
|
33
|
+
{ id: "motion.webp", ok: Boolean(findTool("img2webp", env)), detail: findTool("img2webp", env) ?? "img2webp not found", needed: "motion loop (WebP)" },
|
|
34
|
+
];
|
|
35
|
+
const status = !checks[0].ok || !writable ? "BLOCKED" : chrome ? "PASS" : "DEGRADED";
|
|
36
|
+
return {
|
|
37
|
+
status,
|
|
38
|
+
checks,
|
|
39
|
+
setup: [
|
|
40
|
+
...(chrome ? [] : ["Install Google Chrome (or Chromium), or point CHROME_PATH at an existing binary. lit-schematic never installs it for you."]),
|
|
41
|
+
...(findTool("ffmpeg", env) && findTool("img2webp", env) ? [] : ["Only for motion loops: install ffmpeg and the WebP tools (img2webp) with your package manager. lit-schematic never installs them."]),
|
|
42
|
+
],
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
|
47
|
+
const args = process.argv.slice(2);
|
|
48
|
+
const i = args.indexOf("--out");
|
|
49
|
+
const result = diagnose({ out: i >= 0 ? args[i + 1] : process.cwd() });
|
|
50
|
+
if (args.includes("--json")) console.log(JSON.stringify(result, null, 2));
|
|
51
|
+
else {
|
|
52
|
+
for (const c of result.checks) console.log(`${c.ok ? "ok " : "MISS"} ${c.id.padEnd(12)} ${c.detail} [${c.needed}]`);
|
|
53
|
+
for (const line of result.setup) console.log(`setup: ${line}`);
|
|
54
|
+
console.log(`DOCTOR ${result.status}`);
|
|
55
|
+
}
|
|
56
|
+
process.exitCode = args.includes("--strict") && result.status !== "PASS" ? 1 : 0;
|
|
57
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Journal figure pages: an exact column width in millimetres, panels with (a), (b) labels at
|
|
2
|
+
// their top left, a key of stream colours, and the same mm user space as every other kit
|
|
3
|
+
// drawing so the QA gate reads sizes at print scale.
|
|
4
|
+
|
|
5
|
+
import { createCanvas } from "./sheet.mjs";
|
|
6
|
+
import { el, esc, n, pathOf, ptToMm, svgDocument } from "./svg.mjs";
|
|
7
|
+
import { JOURNAL, STREAM, fontFor } from "./theme.mjs";
|
|
8
|
+
|
|
9
|
+
export const WIDTHS = JOURNAL.width;
|
|
10
|
+
|
|
11
|
+
export function createFigure({ width = "double", height, title, desc = "", lang = "en", mode = "journal", labelStyle = "paren", textPt = JOURNAL.textPt } = {}) {
|
|
12
|
+
const w = typeof width === "number" ? width : WIDTHS[width];
|
|
13
|
+
if (!w) throw new Error(`createFigure: width must be a number of mm or one of ${Object.keys(WIDTHS).join(", ")}`);
|
|
14
|
+
if (!height) throw new Error("createFigure: height in mm is required");
|
|
15
|
+
if (height > JOURNAL.maxHeight) throw new Error(`createFigure: ${height} mm is taller than a journal page allows (${JOURNAL.maxHeight} mm)`);
|
|
16
|
+
const panels = [];
|
|
17
|
+
const layers = [];
|
|
18
|
+
const text = ptToMm(textPt);
|
|
19
|
+
const panelText = ptToMm(JOURNAL.panelPt);
|
|
20
|
+
|
|
21
|
+
function panel(label, [x, y, pw, ph]) {
|
|
22
|
+
const box = [x, y, x + pw, y + ph];
|
|
23
|
+
const inner = label === null ? box : [x, y + panelText + 1.6, x + pw, y + ph];
|
|
24
|
+
const entry = { label, box, inner, body: [] };
|
|
25
|
+
panels.push(entry);
|
|
26
|
+
return {
|
|
27
|
+
box,
|
|
28
|
+
inner,
|
|
29
|
+
label,
|
|
30
|
+
canvas: (options = {}) => {
|
|
31
|
+
const c = createCanvas({ box: options.box ?? inner, style: "journal", ...options });
|
|
32
|
+
entry.body.push(() => c.render());
|
|
33
|
+
entry.canvas = c;
|
|
34
|
+
return c;
|
|
35
|
+
},
|
|
36
|
+
iso: (view, { box: target = inner, labelColumn = null, calloutSize = text } = {}) => {
|
|
37
|
+
const fitted = view.fit(target, { labelColumn, size: calloutSize });
|
|
38
|
+
entry.view = view;
|
|
39
|
+
entry.body.push(() => view.render() + view.renderCallouts({ box: target, size: calloutSize }));
|
|
40
|
+
return fitted;
|
|
41
|
+
},
|
|
42
|
+
text: (tx, ty, content, { size = text, anchor = "start", bold = false, color = "#000000", kind = "note" } = {}) => {
|
|
43
|
+
entry.body.push(() => el("text", { x: tx, y: ty, "font-family": fontFor(content), "font-size": size, "text-anchor": anchor, "font-weight": bold ? "bold" : undefined, fill: color, "data-label": kind }, esc(content)));
|
|
44
|
+
},
|
|
45
|
+
raw: (svg) => entry.body.push(() => svg),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// A key of stream roles: a short line swatch and its name, one row each.
|
|
50
|
+
function key(roles, [x, y], { columns = 1, colWidth = 34, rowHeight = text + 1.6 } = {}) {
|
|
51
|
+
const rows = roles.map((role, i) => {
|
|
52
|
+
const spec = STREAM[role];
|
|
53
|
+
if (!spec) throw new Error(`key: unknown stream role ${role}`);
|
|
54
|
+
const cx = x + (i % columns) * colWidth;
|
|
55
|
+
const cy = y + Math.floor(i / columns) * rowHeight;
|
|
56
|
+
return el("g", { "data-legend-item": `role:${role}` },
|
|
57
|
+
el("path", { d: pathOf([[cx, cy - text * 0.3], [cx + 7, cy - text * 0.3]]), stroke: spec.color, "stroke-width": ptToMm(JOURNAL.linePt * 1.4), "stroke-dasharray": spec.dash ?? undefined, fill: "none", "data-legend-line": role }),
|
|
58
|
+
el("text", { x: cx + 9, y: cy, "font-family": fontFor(spec.label), "font-size": text, fill: "#000000", "data-label": "legend" }, esc(spec.label)));
|
|
59
|
+
});
|
|
60
|
+
layers.push(el("g", { "data-key": "streams" }, rows));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function render() {
|
|
64
|
+
const body = [el("rect", { x: 0, y: 0, width: w, height, fill: "#ffffff" })];
|
|
65
|
+
panels.forEach((p) => {
|
|
66
|
+
if (p.label === null) {
|
|
67
|
+
body.push(el("g", { "data-figure-body": "1" }, p.body.map((f) => f())));
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const [x0, y0] = p.box;
|
|
71
|
+
const shown = labelStyle === "paren" ? `(${p.label})` : labelStyle === "bold" ? p.label : p.label.toUpperCase();
|
|
72
|
+
body.push(el("g", { "data-panel": p.label, "data-panel-box": p.box.map(n).join(" ") },
|
|
73
|
+
el("text", { x: x0 + 0.2, y: y0 + panelText * 0.8, "font-family": fontFor(shown), "font-size": panelText, "font-weight": "bold", fill: "#000000", "data-label": "panel-label" }, esc(shown)),
|
|
74
|
+
p.body.map((f) => f())));
|
|
75
|
+
});
|
|
76
|
+
body.push(...layers);
|
|
77
|
+
return svgDocument({ width: w, height, title, desc, mode, lang, body: body.join(""), extra: { "data-min-text-pt": JOURNAL.minTextPt } });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return { width: w, height, text, panel, key, render, warnings: () => panels.flatMap((p) => [...(p.canvas?.warnings ?? []), ...(p.view?.warnings ?? [])]) };
|
|
81
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Locates fonts for rendering. Pretendard ships with the sibling lit-diagram-drawer skill
|
|
2
|
+
// (unmodified official file under the OFL); lit-schematic reads it from there instead of carrying
|
|
3
|
+
// a second copy. Both skills install side by side under one skills directory.
|
|
4
|
+
|
|
5
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { join, resolve } from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
|
|
10
|
+
export const SKILL_ROOT = resolve(fileURLToPath(new URL("../..", import.meta.url)));
|
|
11
|
+
|
|
12
|
+
export function findPretendard() {
|
|
13
|
+
const sibling = join(SKILL_ROOT, "..", "lit-diagram-drawer", "assets", "fonts", "PretendardVariable.woff2");
|
|
14
|
+
if (existsSync(sibling)) return { file: sibling, source: "skill" };
|
|
15
|
+
const dirs = [join(homedir(), "Library", "Fonts"), "/Library/Fonts", join(homedir(), ".local", "share", "fonts"), "/usr/share/fonts", "C:\\Windows\\Fonts"];
|
|
16
|
+
for (const dir of dirs) {
|
|
17
|
+
try {
|
|
18
|
+
const hit = readdirSync(dir).find((name) => /^Pretendard.*\.(?:otf|ttf|woff2)$/iu.test(name));
|
|
19
|
+
if (hit) return { file: join(dir, hit), source: "system" };
|
|
20
|
+
} catch {
|
|
21
|
+
// directory absent on this platform
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Plane geometry shared by the router, the label placer and the QA gate.
|
|
2
|
+
// Boxes are [x0, y0, x1, y1] with x0 <= x1 and y0 <= y1; points are [x, y].
|
|
3
|
+
|
|
4
|
+
export const EPS = 1e-6;
|
|
5
|
+
|
|
6
|
+
export const boxOf = (points) => {
|
|
7
|
+
const xs = points.map((p) => p[0]);
|
|
8
|
+
const ys = points.map((p) => p[1]);
|
|
9
|
+
return [Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys)];
|
|
10
|
+
};
|
|
11
|
+
export const grow = ([x0, y0, x1, y1], by) => [x0 - by, y0 - by, x1 + by, y1 + by];
|
|
12
|
+
export const union = (a, b) => [Math.min(a[0], b[0]), Math.min(a[1], b[1]), Math.max(a[2], b[2]), Math.max(a[3], b[3])];
|
|
13
|
+
export const area = ([x0, y0, x1, y1]) => Math.max(0, x1 - x0) * Math.max(0, y1 - y0);
|
|
14
|
+
export const boxesOverlap = (a, b, by = 0) => a[0] < b[2] - by && a[2] > b[0] + by && a[1] < b[3] - by && a[3] > b[1] + by;
|
|
15
|
+
export const overlapArea = (a, b) => area([Math.max(a[0], b[0]), Math.max(a[1], b[1]), Math.min(a[2], b[2]), Math.min(a[3], b[3])]);
|
|
16
|
+
export const contains = (outer, inner, by = 0) =>
|
|
17
|
+
inner[0] >= outer[0] - by && inner[1] >= outer[1] - by && inner[2] <= outer[2] + by && inner[3] <= outer[3] + by;
|
|
18
|
+
export const pointIn = ([x, y], [x0, y0, x1, y1], by = 0) => x > x0 + by && x < x1 - by && y > y0 + by && y < y1 - by;
|
|
19
|
+
export const dist = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1]);
|
|
20
|
+
|
|
21
|
+
export function pointSegmentDistance(p, a, b) {
|
|
22
|
+
const dx = b[0] - a[0];
|
|
23
|
+
const dy = b[1] - a[1];
|
|
24
|
+
const span = dx * dx + dy * dy;
|
|
25
|
+
const t = span > 0 ? Math.max(0, Math.min(1, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / span)) : 0;
|
|
26
|
+
return Math.hypot(p[0] - a[0] - t * dx, p[1] - a[1] - t * dy);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Proper intersection point of two segments, or null. Touching at endpoints counts only when
|
|
30
|
+
// `touch` is true; collinear overlaps are reported separately by `collinearOverlap`.
|
|
31
|
+
export function segmentIntersection(a, b, c, d, touch = false) {
|
|
32
|
+
const r = [b[0] - a[0], b[1] - a[1]];
|
|
33
|
+
const s = [d[0] - c[0], d[1] - c[1]];
|
|
34
|
+
const denom = r[0] * s[1] - r[1] * s[0];
|
|
35
|
+
if (Math.abs(denom) < EPS) return null;
|
|
36
|
+
const t = ((c[0] - a[0]) * s[1] - (c[1] - a[1]) * s[0]) / denom;
|
|
37
|
+
const u = ((c[0] - a[0]) * r[1] - (c[1] - a[1]) * r[0]) / denom;
|
|
38
|
+
const lo = touch ? -EPS : 1e-4;
|
|
39
|
+
const hi = touch ? 1 + EPS : 1 - 1e-4;
|
|
40
|
+
if (t < lo || t > hi || u < lo || u > hi) return null;
|
|
41
|
+
return { point: [a[0] + t * r[0], a[1] + t * r[1]], t, u };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function collinearOverlap(a, b, c, d, tolerance = 0.05) {
|
|
45
|
+
const r = [b[0] - a[0], b[1] - a[1]];
|
|
46
|
+
const length = Math.hypot(r[0], r[1]);
|
|
47
|
+
if (length < EPS) return 0;
|
|
48
|
+
const cross = (p) => Math.abs((p[0] - a[0]) * r[1] - (p[1] - a[1]) * r[0]) / length;
|
|
49
|
+
if (cross(c) > tolerance || cross(d) > tolerance) return 0;
|
|
50
|
+
const along = (p) => ((p[0] - a[0]) * r[0] + (p[1] - a[1]) * r[1]) / length;
|
|
51
|
+
const lo = Math.max(0, Math.min(along(c), along(d)));
|
|
52
|
+
const hi = Math.min(length, Math.max(along(c), along(d)));
|
|
53
|
+
return Math.max(0, hi - lo);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Length of a segment that lies strictly inside a box (Liang–Barsky clip).
|
|
57
|
+
export function insideLength(a, b, [x0, y0, x1, y1]) {
|
|
58
|
+
let t0 = 0;
|
|
59
|
+
let t1 = 1;
|
|
60
|
+
const dx = b[0] - a[0];
|
|
61
|
+
const dy = b[1] - a[1];
|
|
62
|
+
for (const [p, q] of [[-dx, a[0] - x0], [dx, x1 - a[0]], [-dy, a[1] - y0], [dy, y1 - a[1]]]) {
|
|
63
|
+
if (Math.abs(p) < EPS) {
|
|
64
|
+
if (q <= 0) return 0;
|
|
65
|
+
} else {
|
|
66
|
+
const t = q / p;
|
|
67
|
+
if (p < 0) t0 = Math.max(t0, t);
|
|
68
|
+
else t1 = Math.min(t1, t);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return t1 > t0 ? (t1 - t0) * Math.hypot(dx, dy) : 0;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const segmentsOf = (points) => points.slice(1).map((point, index) => [points[index], point]);
|
|
75
|
+
|
|
76
|
+
export function pathLength(points) {
|
|
77
|
+
return segmentsOf(points).reduce((sum, [a, b]) => sum + dist(a, b), 0);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function pointAlong(points, at) {
|
|
81
|
+
let left = at;
|
|
82
|
+
for (const [a, b] of segmentsOf(points)) {
|
|
83
|
+
const length = dist(a, b);
|
|
84
|
+
if (left <= length) {
|
|
85
|
+
const t = length ? left / length : 0;
|
|
86
|
+
return { point: [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t], direction: length ? [(b[0] - a[0]) / length, (b[1] - a[1]) / length] : [1, 0] };
|
|
87
|
+
}
|
|
88
|
+
left -= length;
|
|
89
|
+
}
|
|
90
|
+
const [a, b] = segmentsOf(points).at(-1);
|
|
91
|
+
const length = dist(a, b) || 1;
|
|
92
|
+
return { point: b, direction: [(b[0] - a[0]) / length, (b[1] - a[1]) / length] };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Removes repeated points and merges collinear runs, keeping the polyline's corners only.
|
|
96
|
+
export function simplify(points) {
|
|
97
|
+
const out = [];
|
|
98
|
+
for (const point of points) {
|
|
99
|
+
const last = out.at(-1);
|
|
100
|
+
if (last && dist(last, point) < EPS) continue;
|
|
101
|
+
if (out.length >= 2) {
|
|
102
|
+
const prev = out.at(-2);
|
|
103
|
+
const cross = (last[0] - prev[0]) * (point[1] - last[1]) - (last[1] - prev[1]) * (point[0] - last[0]);
|
|
104
|
+
const dot = (last[0] - prev[0]) * (point[0] - last[0]) + (last[1] - prev[1]) * (point[1] - last[1]);
|
|
105
|
+
if (Math.abs(cross) < EPS && dot > 0) {
|
|
106
|
+
out[out.length - 1] = point;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
out.push(point);
|
|
111
|
+
}
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
// One self-contained HTML page for an isometric cutaway: preset views, an exploded view, layer
|
|
2
|
+
// toggles and callouts that light their part on hover or keyboard focus. Nothing is fetched:
|
|
3
|
+
// the drawings, styles and script are inline, so the file works offline and from email.
|
|
4
|
+
//
|
|
5
|
+
// Input is the static art the kit already drew, one SVG per preset view, each carrying the
|
|
6
|
+
// markers the kit writes: data-group on assemblies, data-part/data-hull on parts, data-leader,
|
|
7
|
+
// data-leader-dot, data-for-leader and data-target on callouts. Explode vectors are screen
|
|
8
|
+
// offsets per view (view.screenVector of the world move), so the page needs no geometry.
|
|
9
|
+
//
|
|
10
|
+
// State can be set from the URL hash for checks and screenshots:
|
|
11
|
+
// #view=<id>&explode=1&hide=<group>,<group>&select=<callout id>
|
|
12
|
+
|
|
13
|
+
import { esc } from "./svg.mjs";
|
|
14
|
+
|
|
15
|
+
const CSS = `
|
|
16
|
+
:root{--paper:#ffffff;--ink:#1d1d20;--muted:#5d5d64;--line:#d9d9de;--accent:#0072b2;--accent-soft:rgba(0,114,178,.14);--focus:#0072b2;color-scheme:light}
|
|
17
|
+
*{box-sizing:border-box}
|
|
18
|
+
body{margin:0;background:var(--paper);color:var(--ink);font:15px/1.45 Arial,Helvetica,"Apple SD Gothic Neo","Noto Sans KR",Pretendard,sans-serif}
|
|
19
|
+
main{max-width:1180px;margin:0 auto;padding:20px 16px 28px}
|
|
20
|
+
h1{font-size:22px;margin:0 0 4px}
|
|
21
|
+
.lede{margin:0 0 14px;color:var(--muted);max-width:72ch}
|
|
22
|
+
.bar{display:flex;flex-wrap:wrap;gap:10px 18px;align-items:center;margin:0 0 10px;padding:10px 0;border-top:1px solid var(--line);border-bottom:1px solid var(--line)}
|
|
23
|
+
.group{display:flex;flex-wrap:wrap;gap:6px;align-items:center;border:0;margin:0;padding:0}
|
|
24
|
+
.group>span,.group legend{font-size:12px;letter-spacing:.04em;text-transform:uppercase;color:var(--muted);margin-right:4px;padding:0;float:left}
|
|
25
|
+
button{font:inherit;font-size:14px;padding:6px 12px;border:1px solid var(--line);border-radius:6px;background:#fff;color:var(--ink);cursor:pointer}
|
|
26
|
+
button[aria-pressed="true"]{background:var(--ink);border-color:var(--ink);color:#fff}
|
|
27
|
+
label.toggle{display:inline-flex;gap:6px;align-items:center;font-size:14px;padding:4px 8px;border:1px solid var(--line);border-radius:6px;cursor:pointer}
|
|
28
|
+
button:focus-visible,label.toggle:focus-within,[data-for-leader]:focus-visible{outline:2px solid var(--focus);outline-offset:3px}
|
|
29
|
+
.stage{position:relative}
|
|
30
|
+
.stage svg{display:block;width:100%;height:auto;max-height:max(360px, calc(100vh - 200px))}
|
|
31
|
+
.stage .view[hidden]{display:none}
|
|
32
|
+
[data-for-leader]{cursor:pointer}
|
|
33
|
+
[data-for-leader].on{font-weight:bold;fill:var(--accent)}
|
|
34
|
+
path[data-leader].on{stroke:var(--accent);stroke-width:.32}
|
|
35
|
+
circle[data-leader-dot].on{fill:var(--accent)}
|
|
36
|
+
.hl{fill:var(--accent-soft);stroke:var(--accent);stroke-width:.45;pointer-events:none}
|
|
37
|
+
.stage.exploded [data-leader],.stage.exploded [data-leader-dot]{opacity:.55}
|
|
38
|
+
.hint{margin:10px 0 0;font-size:13px;color:var(--muted)}
|
|
39
|
+
.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
|
|
40
|
+
@media (max-width:640px){h1{font-size:19px}button{padding:6px 9px}}
|
|
41
|
+
`;
|
|
42
|
+
|
|
43
|
+
// The page script: plain DOM, one animation loop that stops when nothing moves.
|
|
44
|
+
const SCRIPT = `
|
|
45
|
+
(() => {
|
|
46
|
+
const data = JSON.parse(document.getElementById("lit-data").textContent);
|
|
47
|
+
const stage = document.querySelector(".stage");
|
|
48
|
+
const live = document.getElementById("live");
|
|
49
|
+
const still = matchMedia("(prefers-reduced-motion: reduce)");
|
|
50
|
+
const state = { view: data.views[0].id, explode: 0, hidden: new Set(), select: null };
|
|
51
|
+
const progress = Object.fromEntries(data.order.map((g) => [g, 0]));
|
|
52
|
+
const target = Object.fromEntries(data.order.map((g) => [g, 0]));
|
|
53
|
+
const starts = {};
|
|
54
|
+
let frame = 0;
|
|
55
|
+
|
|
56
|
+
const svgOf = (id) => stage.querySelector('.view[data-view="' + id + '"] svg');
|
|
57
|
+
const active = () => svgOf(state.view);
|
|
58
|
+
const viewData = (id) => data.views.find((v) => v.id === id);
|
|
59
|
+
const say = (text) => { live.textContent = text; };
|
|
60
|
+
|
|
61
|
+
// Original leader ends, so an exploded part keeps its dot.
|
|
62
|
+
for (const view of data.views) {
|
|
63
|
+
const svg = svgOf(view.id);
|
|
64
|
+
for (const path of svg.querySelectorAll("path[data-leader]")) path.dataset.d0 = path.getAttribute("d");
|
|
65
|
+
for (const dot of svg.querySelectorAll("circle[data-leader-dot]")) { dot.dataset.cx0 = dot.getAttribute("cx"); dot.dataset.cy0 = dot.getAttribute("cy"); }
|
|
66
|
+
for (const label of svg.querySelectorAll("[data-for-leader]")) {
|
|
67
|
+
label.setAttribute("tabindex", "0");
|
|
68
|
+
label.setAttribute("role", "button");
|
|
69
|
+
label.setAttribute("aria-pressed", "false");
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const ease = (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
|
|
74
|
+
const offsetOf = (node, view) => {
|
|
75
|
+
let dx = 0, dy = 0;
|
|
76
|
+
for (let n = node; n && n.tagName !== "svg"; n = n.parentNode) {
|
|
77
|
+
const g = n.getAttribute && n.getAttribute("data-group");
|
|
78
|
+
const v = g && viewData(view).explode[g];
|
|
79
|
+
if (v) { dx += v[0] * progress[g]; dy += v[1] * progress[g]; }
|
|
80
|
+
}
|
|
81
|
+
return [dx, dy];
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
function place(view) {
|
|
85
|
+
const svg = svgOf(view);
|
|
86
|
+
const vd = viewData(view);
|
|
87
|
+
for (const node of svg.querySelectorAll("[data-group]")) {
|
|
88
|
+
const g = node.getAttribute("data-group");
|
|
89
|
+
const v = vd.explode[g];
|
|
90
|
+
const p = progress[g] || 0;
|
|
91
|
+
if (v && p > 0) node.setAttribute("transform", "translate(" + (v[0] * p).toFixed(3) + " " + (v[1] * p).toFixed(3) + ")");
|
|
92
|
+
else node.removeAttribute("transform");
|
|
93
|
+
// visibility, not display: an assembly nested in a hidden one (a bundle inside its shell) stays.
|
|
94
|
+
node.style.visibility = state.hidden.has(g) ? "hidden" : "visible";
|
|
95
|
+
}
|
|
96
|
+
for (const path of svg.querySelectorAll("path[data-leader]")) {
|
|
97
|
+
const id = path.getAttribute("data-leader");
|
|
98
|
+
const part = svg.querySelector('[data-part="' + CSS.escape(path.getAttribute("data-target") || "") + '"]');
|
|
99
|
+
const [dx, dy] = part ? offsetOf(part, view) : [0, 0];
|
|
100
|
+
const pts = path.dataset.d0.match(/-?[\\d.]+/g).map(Number);
|
|
101
|
+
pts[pts.length - 2] += dx; pts[pts.length - 1] += dy;
|
|
102
|
+
path.setAttribute("d", "M" + pts[0] + " " + pts[1] + pts.slice(2).reduce((s, v, i) => s + (i % 2 ? " " + v : "L" + v), ""));
|
|
103
|
+
const dot = svg.querySelector('circle[data-leader-dot="' + id + '"]');
|
|
104
|
+
dot.setAttribute("cx", (Number(dot.dataset.cx0) + dx).toFixed(3));
|
|
105
|
+
dot.setAttribute("cy", (Number(dot.dataset.cy0) + dy).toFixed(3));
|
|
106
|
+
const hiddenPart = part && part.closest("[data-group]") && [...state.hidden].some((g) => part.closest('[data-group="' + g + '"]'));
|
|
107
|
+
for (const el of [path, dot, svg.querySelector('[data-for-leader="' + id + '"]')]) el.style.display = hiddenPart ? "none" : "";
|
|
108
|
+
}
|
|
109
|
+
highlight(view);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function highlight(view) {
|
|
113
|
+
const svg = svgOf(view);
|
|
114
|
+
svg.querySelectorAll(".hl").forEach((n) => n.remove());
|
|
115
|
+
svg.querySelectorAll(".on").forEach((n) => n.classList.remove("on"));
|
|
116
|
+
svg.querySelectorAll("[data-for-leader]").forEach((n) => n.setAttribute("aria-pressed", "false"));
|
|
117
|
+
if (!state.select) return;
|
|
118
|
+
const label = svg.querySelector('[data-for-leader="' + state.select + '"]');
|
|
119
|
+
if (!label) return;
|
|
120
|
+
const path = svg.querySelector('path[data-leader="' + state.select + '"]');
|
|
121
|
+
const part = svg.querySelector('[data-part="' + CSS.escape(path.getAttribute("data-target") || "") + '"]');
|
|
122
|
+
[label, path, svg.querySelector('circle[data-leader-dot="' + state.select + '"]')].forEach((n) => n.classList.add("on"));
|
|
123
|
+
label.setAttribute("aria-pressed", "true");
|
|
124
|
+
if (part && part.dataset.hull) {
|
|
125
|
+
const [dx, dy] = offsetOf(part, view);
|
|
126
|
+
const poly = document.createElementNS("http://www.w3.org/2000/svg", "polygon");
|
|
127
|
+
poly.setAttribute("class", "hl");
|
|
128
|
+
poly.setAttribute("points", part.dataset.hull.split(" ").map((p) => p.split(",").map(Number)).map(([x, y]) => (x + dx).toFixed(2) + "," + (y + dy).toFixed(2)).join(" "));
|
|
129
|
+
svg.querySelector("[data-callouts]").before(poly);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function tick(now) {
|
|
134
|
+
let moving = false;
|
|
135
|
+
for (const g of data.order) {
|
|
136
|
+
const s = starts[g];
|
|
137
|
+
if (!s) continue;
|
|
138
|
+
const t = Math.min(1, Math.max(0, (now - s.at) / data.duration));
|
|
139
|
+
progress[g] = s.from + (target[g] - s.from) * ease(t);
|
|
140
|
+
if (t < 1) moving = true; else delete starts[g];
|
|
141
|
+
}
|
|
142
|
+
place(state.view);
|
|
143
|
+
frame = moving ? requestAnimationFrame(tick) : 0;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function setExplode(on, announce = true) {
|
|
147
|
+
state.explode = on ? 1 : 0;
|
|
148
|
+
stage.classList.toggle("exploded", !!on);
|
|
149
|
+
document.getElementById("explode").setAttribute("aria-pressed", String(!!on));
|
|
150
|
+
// Outside parts leave first and come home last.
|
|
151
|
+
const order = on ? data.order : [...data.order].reverse();
|
|
152
|
+
const now = performance.now();
|
|
153
|
+
order.forEach((g, i) => {
|
|
154
|
+
target[g] = on ? 1 : 0;
|
|
155
|
+
if (still.matches) { progress[g] = target[g]; delete starts[g]; }
|
|
156
|
+
else starts[g] = { from: progress[g], at: now + i * data.stagger };
|
|
157
|
+
});
|
|
158
|
+
if (still.matches) place(state.view);
|
|
159
|
+
else if (!frame) frame = requestAnimationFrame(tick);
|
|
160
|
+
if (announce) say(on ? data.text.exploded : data.text.assembled);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function setView(id, announce = true) {
|
|
164
|
+
state.view = id;
|
|
165
|
+
for (const node of stage.querySelectorAll(".view")) node.hidden = node.dataset.view !== id;
|
|
166
|
+
for (const b of document.querySelectorAll("[data-view-button]")) b.setAttribute("aria-pressed", String(b.dataset.viewButton === id));
|
|
167
|
+
place(id);
|
|
168
|
+
if (announce) say(viewData(id).label);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function select(id, announce = true) {
|
|
172
|
+
state.select = state.select === id ? null : id;
|
|
173
|
+
highlight(state.view);
|
|
174
|
+
if (announce && state.select) say(active().querySelector('[data-for-leader="' + state.select + '"]').textContent);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
document.querySelectorAll("[data-view-button]").forEach((b) => b.addEventListener("click", () => setView(b.dataset.viewButton)));
|
|
178
|
+
document.getElementById("explode").addEventListener("click", () => setExplode(!state.explode));
|
|
179
|
+
document.querySelectorAll("[data-layer]").forEach((box) => box.addEventListener("change", () => {
|
|
180
|
+
if (box.checked) state.hidden.delete(box.dataset.layer); else state.hidden.add(box.dataset.layer);
|
|
181
|
+
place(state.view);
|
|
182
|
+
say((box.checked ? data.text.shown : data.text.hidden) + " " + box.dataset.label);
|
|
183
|
+
}));
|
|
184
|
+
stage.addEventListener("click", (e) => {
|
|
185
|
+
const label = e.target.closest("[data-for-leader]");
|
|
186
|
+
if (label) { select(label.getAttribute("data-for-leader")); return; }
|
|
187
|
+
const part = e.target.closest("[data-part]");
|
|
188
|
+
const id = part && partCallout(part);
|
|
189
|
+
if (id) select(id);
|
|
190
|
+
});
|
|
191
|
+
stage.addEventListener("keydown", (e) => {
|
|
192
|
+
const label = e.target.closest && e.target.closest("[data-for-leader]");
|
|
193
|
+
if (label && (e.key === "Enter" || e.key === " ")) { e.preventDefault(); select(label.getAttribute("data-for-leader")); }
|
|
194
|
+
});
|
|
195
|
+
stage.addEventListener("focusin", (e) => {
|
|
196
|
+
const label = e.target.closest && e.target.closest("[data-for-leader]");
|
|
197
|
+
if (label && state.select !== label.getAttribute("data-for-leader")) select(label.getAttribute("data-for-leader"));
|
|
198
|
+
});
|
|
199
|
+
// Hover previews a part's callout without changing the selection the keyboard made.
|
|
200
|
+
let hoverFrom = null;
|
|
201
|
+
stage.addEventListener("pointermove", (e) => {
|
|
202
|
+
const label = e.target.closest("[data-for-leader]");
|
|
203
|
+
const part = !label && e.target.closest("[data-part]");
|
|
204
|
+
const id = label ? label.getAttribute("data-for-leader") : part ? partCallout(part) : null;
|
|
205
|
+
if (id && id !== state.select) { if (hoverFrom === null) hoverFrom = state.select; state.select = id; highlight(state.view); }
|
|
206
|
+
if (!id && hoverFrom !== null) { state.select = hoverFrom; hoverFrom = null; highlight(state.view); }
|
|
207
|
+
});
|
|
208
|
+
stage.addEventListener("pointerleave", () => { if (hoverFrom !== null) { state.select = hoverFrom; hoverFrom = null; highlight(state.view); } });
|
|
209
|
+
document.addEventListener("keydown", (e) => {
|
|
210
|
+
// A held key repeats; a toggle should flip once per press.
|
|
211
|
+
if (e.repeat || e.altKey || e.ctrlKey || e.metaKey) return;
|
|
212
|
+
if (e.target.closest && e.target.closest("input,button")) { if (e.key !== "Escape") return; }
|
|
213
|
+
if (e.key === "Escape") { state.select = null; highlight(state.view); }
|
|
214
|
+
if (e.key === "e" || e.key === "E") setExplode(!state.explode);
|
|
215
|
+
const n = Number(e.key);
|
|
216
|
+
if (n >= 1 && n <= data.views.length) setView(data.views[n - 1].id);
|
|
217
|
+
});
|
|
218
|
+
function partCallout(part) {
|
|
219
|
+
for (let n = part; n && n.tagName !== "svg"; n = n.parentNode) {
|
|
220
|
+
const name = n.getAttribute && n.getAttribute("data-part");
|
|
221
|
+
if (!name) continue;
|
|
222
|
+
const path = active().querySelector('path[data-leader][data-target="' + CSS.escape(name) + '"]');
|
|
223
|
+
if (path) return path.getAttribute("data-leader");
|
|
224
|
+
}
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Initial state, optionally from the hash.
|
|
229
|
+
const hash = new URLSearchParams(location.hash.slice(1));
|
|
230
|
+
for (const g of (hash.get("hide") || "").split(",").filter(Boolean)) {
|
|
231
|
+
state.hidden.add(g);
|
|
232
|
+
const box = document.querySelector('[data-layer="' + g + '"]');
|
|
233
|
+
if (box) box.checked = false;
|
|
234
|
+
}
|
|
235
|
+
setView(viewData(hash.get("view")) ? hash.get("view") : state.view, false);
|
|
236
|
+
if (hash.get("explode") === "1") { for (const g of data.order) { progress[g] = 1; target[g] = 1; } state.explode = 1; stage.classList.add("exploded"); document.getElementById("explode").setAttribute("aria-pressed", "true"); }
|
|
237
|
+
if (hash.get("select")) state.select = hash.get("select");
|
|
238
|
+
place(state.view);
|
|
239
|
+
document.documentElement.dataset.ready = "1";
|
|
240
|
+
})();
|
|
241
|
+
`;
|
|
242
|
+
|
|
243
|
+
export function renderInteractive({
|
|
244
|
+
title, description = "", lang = "en", views, layers = [], order = null,
|
|
245
|
+
duration = 700, stagger = 90, hint = null,
|
|
246
|
+
text = {},
|
|
247
|
+
} = {}) {
|
|
248
|
+
if (!title) throw new Error("renderInteractive: title is required");
|
|
249
|
+
if (!views?.length) throw new Error("renderInteractive: give at least one view { id, label, svg, explode }");
|
|
250
|
+
const groups = order ?? [...new Set(views.flatMap((v) => Object.keys(v.explode ?? {})))];
|
|
251
|
+
const words = {
|
|
252
|
+
views: "View", layers: "Layers", explode: "Exploded view", exploded: "Exploded view", assembled: "Assembled",
|
|
253
|
+
shown: "Showing", hidden: "Hiding",
|
|
254
|
+
hint: "Hover or Tab to a label to light its part. E explodes, 1 to " + views.length + " switch views, Esc clears.",
|
|
255
|
+
...text,
|
|
256
|
+
};
|
|
257
|
+
const data = {
|
|
258
|
+
views: views.map((v) => ({ id: v.id, label: v.label, explode: v.explode ?? {} })),
|
|
259
|
+
order: groups, duration, stagger,
|
|
260
|
+
text: { exploded: words.exploded, assembled: words.assembled, shown: words.shown, hidden: words.hidden },
|
|
261
|
+
};
|
|
262
|
+
const stripProlog = (svg) => svg.replace(/^\s*<\?xml[^>]*>\s*/u, "");
|
|
263
|
+
const viewButtons = views.map((v, i) => `<button type="button" data-view-button="${esc(v.id)}" aria-pressed="${i === 0}" aria-keyshortcuts="${i + 1}">${esc(v.label)}</button>`).join("");
|
|
264
|
+
const layerBoxes = layers.map((l) => `<label class="toggle"><input type="checkbox" checked data-layer="${esc(l.group)}" data-label="${esc(l.label)}">${esc(l.label)}</label>`).join("");
|
|
265
|
+
const stages = views.map((v, i) => `<div class="view" data-view="${esc(v.id)}"${i ? " hidden" : ""}>${stripProlog(v.svg)}</div>`).join("");
|
|
266
|
+
return `<!doctype html>
|
|
267
|
+
<html lang="${esc(lang)}">
|
|
268
|
+
<head>
|
|
269
|
+
<meta charset="utf-8">
|
|
270
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
271
|
+
<title>${esc(title)}</title>
|
|
272
|
+
<style>${CSS}</style>
|
|
273
|
+
</head>
|
|
274
|
+
<body>
|
|
275
|
+
<main>
|
|
276
|
+
<h1>${esc(title)}</h1>
|
|
277
|
+
${description ? `<p class="lede">${esc(description)}</p>` : ""}
|
|
278
|
+
<div class="bar">
|
|
279
|
+
<div class="group" role="group" aria-label="${esc(words.views)}"><span aria-hidden="true">${esc(words.views)}</span>${viewButtons}</div>
|
|
280
|
+
<button type="button" id="explode" aria-pressed="false" aria-keyshortcuts="E">${esc(words.explode)}</button>
|
|
281
|
+
${layers.length ? `<fieldset class="group"><legend>${esc(words.layers)}</legend>${layerBoxes}</fieldset>` : ""}
|
|
282
|
+
</div>
|
|
283
|
+
<div class="stage">${stages}</div>
|
|
284
|
+
<p class="hint">${esc(hint ?? words.hint)}</p>
|
|
285
|
+
<p id="live" class="sr" aria-live="polite"></p>
|
|
286
|
+
</main>
|
|
287
|
+
<script type="application/json" id="lit-data">${JSON.stringify(data).replace(/</gu, "\\u003c")}</script>
|
|
288
|
+
<script>${SCRIPT}</script>
|
|
289
|
+
</body>
|
|
290
|
+
</html>
|
|
291
|
+
`;
|
|
292
|
+
}
|