@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,890 @@
|
|
|
1
|
+
// Canvas and drawing sheet for P&IDs, PFDs and 2D apparatus panels.
|
|
2
|
+
//
|
|
3
|
+
// A canvas owns symbols and lines in one millimetre space: it routes pipes between ports,
|
|
4
|
+
// places inline valves by splitting the pipe under them, hops later process lines over
|
|
5
|
+
// earlier ones, puts junction dots on branches, places line numbers and equipment tags
|
|
6
|
+
// where they collide with nothing, and records a warning for anything it could not place.
|
|
7
|
+
// A sheet is a canvas on an ISO A-series page with a zone frame, an equipment header band,
|
|
8
|
+
// a legend built from what was drawn, numbered notes and an ISO 7200 style title block.
|
|
9
|
+
|
|
10
|
+
import { boxesOverlap, contains, dist, grow, pathLength, pointAlong, segmentIntersection, segmentsOf } from "./geom.mjs";
|
|
11
|
+
import { parseInstrumentTag } from "./isa.mjs";
|
|
12
|
+
import * as pid from "./pid.mjs";
|
|
13
|
+
import { orthogonal, routeOrthogonal } from "./route.mjs";
|
|
14
|
+
import { el, esc, n, pathOf, svgDocument, textBox, textWidthEm } from "./svg.mjs";
|
|
15
|
+
import { FONT_LATIN, PID, STREAM, fontFor } from "./theme.mjs";
|
|
16
|
+
|
|
17
|
+
const W = PID.weight;
|
|
18
|
+
|
|
19
|
+
// Pipe classes differ by weight (PID.weight); instrument lines follow ANSI/ISA-5.1-2009
|
|
20
|
+
// Table 5.3.1 (instrument to process: thin solid) and Table 5.3.2 (instrument to instrument:
|
|
21
|
+
// each signal medium has its own mark).
|
|
22
|
+
export const LINE_KINDS = Object.freeze({
|
|
23
|
+
"process-major": { width: W.major, label: "Main process line" },
|
|
24
|
+
"process-minor": { width: W.minor, label: "Minor process line" },
|
|
25
|
+
utility: { width: W.utility, label: "Utility line" },
|
|
26
|
+
connection: { width: W.connection, label: "Instrument supply or process connection" },
|
|
27
|
+
"signal-electric": { width: W.signal, dash: "1.6 0.9", label: "Electric or electronic signal" },
|
|
28
|
+
"signal-pneumatic": { width: W.signal, marks: "slash", label: "Pneumatic signal" },
|
|
29
|
+
"signal-hydraulic": { width: W.signal, marks: "ell", label: "Hydraulic signal" },
|
|
30
|
+
"signal-capillary": { width: W.signal, marks: "cross", label: "Filled-system capillary tube" },
|
|
31
|
+
"signal-guided": { width: W.signal, marks: "wave", label: "Guided electromagnetic or sonic signal" },
|
|
32
|
+
"signal-unguided": { width: W.signal, marks: "wave-gap", label: "Unguided electromagnetic signal (radio)" },
|
|
33
|
+
"signal-data": { width: W.signal, marks: "circle", label: "Data link within a control system" },
|
|
34
|
+
"signal-mechanical": { width: W.signal, marks: "dot", label: "Mechanical link" },
|
|
35
|
+
stream: { width: 0.35, label: "Process stream" },
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Round-1 names that builds may still pass.
|
|
39
|
+
const SIGNAL_ALIASES = Object.freeze({ software: "data" });
|
|
40
|
+
|
|
41
|
+
const isProcess = (kind) => kind === "process-major" || kind === "process-minor" || kind === "utility" || kind === "stream";
|
|
42
|
+
const isWave = (kind) => LINE_KINDS[kind]?.marks === "wave" || LINE_KINDS[kind]?.marks === "wave-gap";
|
|
43
|
+
|
|
44
|
+
// A sine wave riding a polyline, for electromagnetic and sonic signals. With gaps, every other
|
|
45
|
+
// period is left out so the wave reads as broken (unguided).
|
|
46
|
+
function wavePath(points, { amplitude = 0.7, period = 2.4, gaps = false } = {}) {
|
|
47
|
+
const total = pathLength(points);
|
|
48
|
+
const step = period / 12;
|
|
49
|
+
let d = "";
|
|
50
|
+
let pen = false;
|
|
51
|
+
for (let s = 0; s <= total + 1e-6; s += step) {
|
|
52
|
+
const { point: [x, y], direction: [dx, dy] } = pointAlong(points, Math.min(s, total));
|
|
53
|
+
const phase = (s / period) * Math.PI * 2;
|
|
54
|
+
const visible = !gaps || Math.floor(s / period) % 2 === 0 || s < 0.5 || s > total - 0.5;
|
|
55
|
+
const off = s < 0.6 || s > total - 0.6 ? 0 : Math.sin(phase) * amplitude;
|
|
56
|
+
const px = x - dy * off;
|
|
57
|
+
const py = y + dx * off;
|
|
58
|
+
if (!visible) { pen = false; continue; }
|
|
59
|
+
d += `${pen ? "L" : "M"}${n(px)} ${n(py)}`;
|
|
60
|
+
pen = true;
|
|
61
|
+
}
|
|
62
|
+
return d;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const SHEETS = Object.freeze({ A4: [297, 210], A3: [420, 297], A2: [594, 420], A1: [841, 594] });
|
|
66
|
+
|
|
67
|
+
function wrap(text, width, size) {
|
|
68
|
+
const words = String(text).split(/\s+/u).filter(Boolean);
|
|
69
|
+
const lines = [];
|
|
70
|
+
let current = "";
|
|
71
|
+
for (const word of words) {
|
|
72
|
+
const next = current ? `${current} ${word}` : word;
|
|
73
|
+
if (current && textWidthEm(next) * size > width) {
|
|
74
|
+
lines.push(current);
|
|
75
|
+
current = word;
|
|
76
|
+
} else current = next;
|
|
77
|
+
}
|
|
78
|
+
if (current) lines.push(current);
|
|
79
|
+
return lines;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const textEl = (x, y, text, size, extra = {}) =>
|
|
83
|
+
el("text", { x, y, "font-family": fontFor(text), "font-size": size, fill: PID.ink, ...extra }, esc(text));
|
|
84
|
+
|
|
85
|
+
function rotatedBox(x, y, text, size, anchor, rotate) {
|
|
86
|
+
const box = textBox(x, y, text, size, anchor);
|
|
87
|
+
if (!rotate) return box;
|
|
88
|
+
// rotate(-90) about (x, y): local (dx, dy) -> (dy, -dx)
|
|
89
|
+
const pts = [[box[0], box[1]], [box[2], box[1]], [box[2], box[3]], [box[0], box[3]]].map(([px, py]) => {
|
|
90
|
+
const dx = px - x;
|
|
91
|
+
const dy = py - y;
|
|
92
|
+
return rotate === -90 ? [x + dy, y - dx] : [x - dy, y + dx];
|
|
93
|
+
});
|
|
94
|
+
return [Math.min(...pts.map((p) => p[0])), Math.min(...pts.map((p) => p[1])), Math.max(...pts.map((p) => p[0])), Math.max(...pts.map((p) => p[1]))];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Arc length along a polyline of the point nearest to p.
|
|
98
|
+
function nearestArc(points, p) {
|
|
99
|
+
let best = { d: Infinity, s: 0 };
|
|
100
|
+
let s0 = 0;
|
|
101
|
+
for (const [a, b] of segmentsOf(points)) {
|
|
102
|
+
const len = dist(a, b);
|
|
103
|
+
const t = len ? Math.max(0, Math.min(1, ((p[0] - a[0]) * (b[0] - a[0]) + (p[1] - a[1]) * (b[1] - a[1])) / (len * len))) : 0;
|
|
104
|
+
const d = dist([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t], p);
|
|
105
|
+
if (d < best.d) best = { d, s: s0 + len * t };
|
|
106
|
+
s0 += len;
|
|
107
|
+
}
|
|
108
|
+
return best.s;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// The marks that name a line class: hash marks, crosses, ells, circles, dots or a wave.
|
|
112
|
+
export function lineMarks(points, kind, stroke, { first = 4, every = 6 } = {}) {
|
|
113
|
+
const spec = LINE_KINDS[kind];
|
|
114
|
+
if (!spec.marks) return "";
|
|
115
|
+
if (isWave(kind)) {
|
|
116
|
+
return el("path", { d: wavePath(points, spec.marks === "wave-gap" ? { gaps: true, period: 3.6, amplitude: 0.8 } : {}), stroke, "stroke-width": spec.width, fill: "none", "stroke-linejoin": "round", "data-line-mark": kind });
|
|
117
|
+
}
|
|
118
|
+
const total = pathLength(points);
|
|
119
|
+
const out = [];
|
|
120
|
+
for (let s = first; s <= total - Math.min(3, first); s += every) {
|
|
121
|
+
const { point: [x, y], direction: [dx, dy] } = pointAlong(points, s);
|
|
122
|
+
const nx = -dy;
|
|
123
|
+
const ny = dx;
|
|
124
|
+
if (spec.marks === "slash") {
|
|
125
|
+
for (const o of [-0.5, 0.5]) {
|
|
126
|
+
const cx = x + dx * o;
|
|
127
|
+
const cy = y + dy * o;
|
|
128
|
+
out.push(pathOf([[cx - nx * 1.1 - dx * 0.6, cy - ny * 1.1 - dy * 0.6], [cx + nx * 1.1 + dx * 0.6, cy + ny * 1.1 + dy * 0.6]]));
|
|
129
|
+
}
|
|
130
|
+
} else if (spec.marks === "cross") {
|
|
131
|
+
out.push(pathOf([[x - nx - dx, y - ny - dy], [x + nx + dx, y + ny + dy]]), pathOf([[x - nx + dx, y - ny + dy], [x + nx - dx, y + ny - dy]]));
|
|
132
|
+
} else if (spec.marks === "ell") {
|
|
133
|
+
out.push(pathOf([[x + nx * 1.2, y + ny * 1.2], [x, y], [x + dx * 1.2, y + dy * 1.2]]));
|
|
134
|
+
} else if (spec.marks === "circle" || spec.marks === "dot") {
|
|
135
|
+
out.push(`M${n(x + 0.55)} ${n(y)}A0.55 0.55 0 1 0 ${n(x - 0.55)} ${n(y)}A0.55 0.55 0 1 0 ${n(x + 0.55)} ${n(y)}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const fill = spec.marks === "circle" ? "#ffffff" : spec.marks === "dot" ? stroke : "none";
|
|
139
|
+
return out.length ? el("path", { d: out.join(""), stroke, "stroke-width": spec.width, fill, "data-line-mark": kind }) : "";
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function createCanvas({ box, style = "pid", grid = style === "pid" ? 2.5 : 1, clearance = style === "pid" ? 2.2 : 1.2, stub = style === "pid" ? 6.2 : 3, labelSize = 2.47 } = {}) {
|
|
143
|
+
if (!box) throw new Error("createCanvas: box [x0, y0, x1, y1] in millimetres is required");
|
|
144
|
+
const symbols = [];
|
|
145
|
+
const lines = [];
|
|
146
|
+
const labels = [];
|
|
147
|
+
const fixedLabels = [];
|
|
148
|
+
const extras = [];
|
|
149
|
+
const warnings = [];
|
|
150
|
+
const junctions = [];
|
|
151
|
+
let lineCounter = 0;
|
|
152
|
+
|
|
153
|
+
const occupiedBoxes = () => symbols.flatMap((s) => s.boxes);
|
|
154
|
+
const labelBoxes = () => labels.map((l) => l.box);
|
|
155
|
+
const zoneSegments = [];
|
|
156
|
+
const drawnSegments = () => [...lines.flatMap((l) => l.pieces.flatMap((p) => segmentsOf(p))), ...zoneSegments];
|
|
157
|
+
|
|
158
|
+
function add(sym) {
|
|
159
|
+
for (const other of symbols) {
|
|
160
|
+
for (const a of sym.boxes) for (const b of other.boxes) {
|
|
161
|
+
if (boxesOverlap(a, b, -0.01)) warnings.push(`symbols overlap: ${sym.kind}${sym.tag ? ` ${sym.tag}` : ""} and ${other.kind}${other.tag ? ` ${other.tag}` : ""}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (!sym.boxes.every((b) => contains(box, b, 0.01))) warnings.push(`symbol outside the canvas: ${sym.kind}${sym.tag ? ` ${sym.tag}` : ""}`);
|
|
165
|
+
symbols.push(sym);
|
|
166
|
+
return sym;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function buildLine({ from, to, via = [], points = null, kind, number = null, role = null, color = null, arrows = "auto", label = null }) {
|
|
170
|
+
if (!LINE_KINDS[kind]) throw new Error(`line kind must be one of ${Object.keys(LINE_KINDS).join(", ")}`);
|
|
171
|
+
let path;
|
|
172
|
+
const existing = lines.map((l) => l.points);
|
|
173
|
+
if (points) {
|
|
174
|
+
path = orthogonal([[from.x, from.y], ...points, [to.x, to.y]], { from, to });
|
|
175
|
+
} else {
|
|
176
|
+
const stops = [from, ...via.map(([x, y]) => ({ x, y, dir: null })), to];
|
|
177
|
+
path = [];
|
|
178
|
+
for (let i = 0; i < stops.length - 1; i++) {
|
|
179
|
+
const a = stops[i];
|
|
180
|
+
const b = stops[i + 1];
|
|
181
|
+
const dirA = a.dir ?? (i > 0 ? null : [1, 0]);
|
|
182
|
+
const dirB = b.dir ?? null;
|
|
183
|
+
const leg = routeOrthogonal({
|
|
184
|
+
from: { x: a.x, y: a.y, dir: dirA ?? guessDir(a, b) },
|
|
185
|
+
to: { x: b.x, y: b.y, dir: dirB ?? guessDir(b, a) },
|
|
186
|
+
obstacles: [...occupiedBoxes(), ...fixedLabels.map((l) => l.box)],
|
|
187
|
+
exempt: symbols.filter((sym) => sym.id === a.owner || sym.id === b.owner).flatMap((sym) => sym.boxes),
|
|
188
|
+
lines: [...existing, path],
|
|
189
|
+
bounds: box,
|
|
190
|
+
grid,
|
|
191
|
+
clearance,
|
|
192
|
+
stub: a.dir ? stub : 0.0001,
|
|
193
|
+
});
|
|
194
|
+
path = path.length ? [...path, ...leg.slice(1)] : leg;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
const id = `L${++lineCounter}`;
|
|
198
|
+
const line = { id, kind, number, role, color, points: path, gaps: [], arrows, label, pieces: [path], from, to, taps: [] };
|
|
199
|
+
line.tap = (at = 0.5) => {
|
|
200
|
+
const total = pathLength(line.points);
|
|
201
|
+
const s = Array.isArray(at) ? nearestArc(line.points, at) : at <= 1 ? at * total : at;
|
|
202
|
+
const { point, direction } = pointAlong(line.points, s);
|
|
203
|
+
if (Array.isArray(at) && dist(point, at) > 1) {
|
|
204
|
+
throw new Error(`tap: (${at}) is ${n(dist(point, at))} mm from ${line.id}; the nearest point on it is (${n(point[0])}, ${n(point[1])})`);
|
|
205
|
+
}
|
|
206
|
+
const corners = line.points.slice(1, -1);
|
|
207
|
+
if (corners.some((c) => dist(c, point) < 3)) {
|
|
208
|
+
throw new Error(`tap: (${n(point[0])}, ${n(point[1])}) is within 3 mm of a bend in ${line.id}; a branch must leave a straight run`);
|
|
209
|
+
}
|
|
210
|
+
const side = [-direction[1], direction[0]];
|
|
211
|
+
const tapPort = { x: point[0], y: point[1], dir: [Math.round(side[0]), Math.round(side[1])], owner: id, name: "tap" };
|
|
212
|
+
tapPort.flip = () => ({ ...tapPort, dir: [-tapPort.dir[0], -tapPort.dir[1]] });
|
|
213
|
+
junctions.push(point);
|
|
214
|
+
line.taps.push(point);
|
|
215
|
+
return tapPort;
|
|
216
|
+
};
|
|
217
|
+
line.at = (fraction) => pointAlong(line.points, fraction * pathLength(line.points));
|
|
218
|
+
lines.push(line);
|
|
219
|
+
return line;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const guessDir = (a, b) => {
|
|
223
|
+
const dx = b.x - a.x;
|
|
224
|
+
const dy = b.y - a.y;
|
|
225
|
+
return Math.abs(dx) >= Math.abs(dy) ? [Math.sign(dx) || 1, 0] : [0, Math.sign(dy) || 1];
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
// Places an inline symbol on a straight run of `line` and gaps the line under it.
|
|
229
|
+
function inline(lineObj, factory, options = {}) {
|
|
230
|
+
const total = pathLength(lineObj.points);
|
|
231
|
+
let at = options.at ?? 0.5;
|
|
232
|
+
if (Array.isArray(at)) at = nearestArc(lineObj.points, at) / total;
|
|
233
|
+
const wanted = at * total;
|
|
234
|
+
// Measure the symbol once at the origin to learn how far its ports reach along the line.
|
|
235
|
+
const probe = factory({ ...options, x: 0, y: 0, orient: "right" });
|
|
236
|
+
const reachA = Math.abs(probe.port("a").x);
|
|
237
|
+
const reachB = Math.abs(probe.port("b").x);
|
|
238
|
+
const margin = 1.5;
|
|
239
|
+
let s = null;
|
|
240
|
+
let corner = 0;
|
|
241
|
+
for (const [a, b] of segmentsOf(lineObj.points)) {
|
|
242
|
+
const len = dist(a, b);
|
|
243
|
+
const lo = corner + reachA + margin;
|
|
244
|
+
const hi = corner + len - reachB - margin;
|
|
245
|
+
if (hi >= lo) {
|
|
246
|
+
const candidate = Math.min(hi, Math.max(lo, wanted));
|
|
247
|
+
if (s === null || Math.abs(candidate - wanted) < Math.abs(s - wanted)) s = candidate;
|
|
248
|
+
}
|
|
249
|
+
corner += len;
|
|
250
|
+
}
|
|
251
|
+
if (s === null) throw new Error(`inline: no straight run of ${lineObj.id} is long enough for ${probe.kind}; lengthen the line or use a smaller symbol`);
|
|
252
|
+
const { point, direction } = pointAlong(lineObj.points, s);
|
|
253
|
+
const orient = direction[0] > 0.5 ? "right" : direction[0] < -0.5 ? "left" : direction[1] > 0.5 ? "down" : "up";
|
|
254
|
+
const sym = factory({ ...options, x: point[0], y: point[1], orient: options.orient ?? orient });
|
|
255
|
+
const sa = s - reachA;
|
|
256
|
+
const sb = s + reachB;
|
|
257
|
+
lineObj.gaps.push([sa, sb]);
|
|
258
|
+
symbols.push(sym);
|
|
259
|
+
return sym;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function finalizePieces(lineObj) {
|
|
263
|
+
const total = pathLength(lineObj.points);
|
|
264
|
+
const gaps = [...lineObj.gaps].sort((a, b) => a[0] - b[0]);
|
|
265
|
+
const spans = [];
|
|
266
|
+
let cursor = 0;
|
|
267
|
+
for (const [g0, g1] of gaps) {
|
|
268
|
+
if (g0 > cursor + 1e-6) spans.push([cursor, g0]);
|
|
269
|
+
cursor = Math.max(cursor, g1);
|
|
270
|
+
}
|
|
271
|
+
if (total > cursor + 1e-6) spans.push([cursor, total]);
|
|
272
|
+
lineObj.pieces = spans.map(([a, b]) => cut(lineObj.points, a, b));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function cut(points, from, to) {
|
|
276
|
+
const out = [pointAlong(points, from).point];
|
|
277
|
+
let s = 0;
|
|
278
|
+
for (const [a, b] of segmentsOf(points)) {
|
|
279
|
+
const len = dist(a, b);
|
|
280
|
+
if (s + len > from + 1e-6 && s + len < to - 1e-6) out.push(b);
|
|
281
|
+
s += len;
|
|
282
|
+
}
|
|
283
|
+
out.push(pointAlong(points, to).point);
|
|
284
|
+
return out;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Draws a polyline with hops where it crosses earlier process lines.
|
|
288
|
+
function hopped(points, earlier) {
|
|
289
|
+
const r = 1.2;
|
|
290
|
+
let d = `M${n(points[0][0])} ${n(points[0][1])}`;
|
|
291
|
+
for (const [a, b] of segmentsOf(points)) {
|
|
292
|
+
const len = dist(a, b);
|
|
293
|
+
const ux = (b[0] - a[0]) / len;
|
|
294
|
+
const uy = (b[1] - a[1]) / len;
|
|
295
|
+
const hits = [];
|
|
296
|
+
for (const [c, e] of earlier) {
|
|
297
|
+
const hit = segmentIntersection(a, b, c, e);
|
|
298
|
+
if (hit && hit.t * len > r + 0.2 && (1 - hit.t) * len > r + 0.2) hits.push(hit.t * len);
|
|
299
|
+
}
|
|
300
|
+
hits.sort((p, q) => p - q);
|
|
301
|
+
for (const s of hits) {
|
|
302
|
+
const p0 = [a[0] + ux * (s - r), a[1] + uy * (s - r)];
|
|
303
|
+
const p1 = [a[0] + ux * (s + r), a[1] + uy * (s + r)];
|
|
304
|
+
const sweep = Math.abs(uy) > 0.5 ? (uy > 0 ? 0 : 1) : ux > 0 ? 1 : 0;
|
|
305
|
+
d += `L${n(p0[0])} ${n(p0[1])}A${n(r)} ${n(r)} 0 0 ${sweep} ${n(p1[0])} ${n(p1[1])}`;
|
|
306
|
+
}
|
|
307
|
+
d += `L${n(b[0])} ${n(b[1])}`;
|
|
308
|
+
}
|
|
309
|
+
return d;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function arrowsFor(lineObj, stroke) {
|
|
313
|
+
if (lineObj.arrows === false || !isProcess(lineObj.kind)) return "";
|
|
314
|
+
const out = [];
|
|
315
|
+
const wanted = Array.isArray(lineObj.arrows) ? lineObj.arrows : null;
|
|
316
|
+
const pieces = lineObj.pieces;
|
|
317
|
+
const candidates = [];
|
|
318
|
+
for (const piece of pieces) for (const [a, b] of segmentsOf(piece)) candidates.push([a, b, dist(a, b)]);
|
|
319
|
+
// An arrow keeps clear of junction dots, symbols and the line's own taps.
|
|
320
|
+
const crowded = (p) => junctions.some((j) => dist(j, p) < 3.5) || occupiedBoxes().some((b) => boxesOverlap(grow(b, 1.5), [p[0], p[1], p[0], p[1]]));
|
|
321
|
+
const auto = () => {
|
|
322
|
+
for (const [a, b, len] of candidates.filter((c) => c[2] >= 10).sort((p, q) => q[2] - p[2])) {
|
|
323
|
+
for (const f of [0.5, 0.35, 0.65, 0.25, 0.75]) {
|
|
324
|
+
const p = [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f];
|
|
325
|
+
if (!crowded(p)) return [[p, [(b[0] - a[0]) / len, (b[1] - a[1]) / len]]];
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return [];
|
|
329
|
+
};
|
|
330
|
+
// An asked-for arrow that lands on a valve or instrument slides along the line to open pipe.
|
|
331
|
+
const inSymbol = (p) => occupiedBoxes().some((b) => boxesOverlap(grow(b, 1.5), [p[0], p[1], p[0], p[1]]));
|
|
332
|
+
const total = pathLength(lineObj.points);
|
|
333
|
+
const asked = (f) => {
|
|
334
|
+
for (let k = 0; k <= 60; k++) {
|
|
335
|
+
for (const sign of k ? [1, -1] : [1]) {
|
|
336
|
+
const g = f + (sign * k * 0.5) / total;
|
|
337
|
+
if (g < 0 || g > 1) continue;
|
|
338
|
+
const { point, direction } = lineObj.at(g);
|
|
339
|
+
if (!inSymbol(point)) return [point, direction];
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
warnings.push(`flow arrow at ${f} on ${lineObj.id} has no open pipe within 30 mm; drop it or move it`);
|
|
343
|
+
const { point, direction } = lineObj.at(f);
|
|
344
|
+
return [point, direction];
|
|
345
|
+
};
|
|
346
|
+
const chosen = wanted ? wanted.map(asked) : auto();
|
|
347
|
+
for (const [[x, y], [dx, dy]] of chosen) {
|
|
348
|
+
const nx = -dy;
|
|
349
|
+
const ny = dx;
|
|
350
|
+
const tip = [x + dx * 1.3, y + dy * 1.3];
|
|
351
|
+
out.push(el("path", { d: pathOf([tip, [x - dx * 1.3 + nx * 0.95, y - dy * 1.3 + ny * 0.95], [x - dx * 1.3 - nx * 0.95, y - dy * 1.3 - ny * 0.95]], true), fill: stroke, stroke: "none", "data-arrow": lineObj.id }));
|
|
352
|
+
}
|
|
353
|
+
return out.join("");
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function placeLabel({ text, size, candidates, owner, extra = {}, bold = false }) {
|
|
357
|
+
const syms = occupiedBoxes();
|
|
358
|
+
const segs = drawnSegments();
|
|
359
|
+
for (const c of candidates) {
|
|
360
|
+
const b = rotatedBox(c.x, c.y, text, size, c.anchor, c.rotate);
|
|
361
|
+
if (!contains(box, b, 0.01)) continue;
|
|
362
|
+
if (syms.some((s) => boxesOverlap(grow(b, 0.4), s))) continue;
|
|
363
|
+
if (labelBoxes().some((l) => boxesOverlap(grow(b, 0.5), l))) continue;
|
|
364
|
+
if (segs.some(([a, e]) => segmentHitsBox(a, e, grow(b, 0.3)))) continue;
|
|
365
|
+
labels.push({ box: b, owner });
|
|
366
|
+
return textEl(c.x, c.y, text, size, {
|
|
367
|
+
"text-anchor": c.anchor,
|
|
368
|
+
transform: c.rotate ? `rotate(${c.rotate} ${n(c.x)} ${n(c.y)})` : undefined,
|
|
369
|
+
"font-weight": bold ? "bold" : undefined,
|
|
370
|
+
...extra,
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
warnings.push(`no clear place for label "${text}" (${owner}); move nearby symbols or shorten it`);
|
|
374
|
+
const c = candidates[0];
|
|
375
|
+
labels.push({ box: rotatedBox(c.x, c.y, text, size, c.anchor, c.rotate), owner });
|
|
376
|
+
return textEl(c.x, c.y, text, size, { "text-anchor": c.anchor, transform: c.rotate ? `rotate(${c.rotate} ${n(c.x)} ${n(c.y)})` : undefined, "font-weight": bold ? "bold" : undefined, ...extra });
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const segmentHitsBox = (a, b, [x0, y0, x1, y1]) => {
|
|
380
|
+
const inside = (p) => p[0] > x0 && p[0] < x1 && p[1] > y0 && p[1] < y1;
|
|
381
|
+
if (inside(a) || inside(b)) return true;
|
|
382
|
+
const edges = [[[x0, y0], [x1, y0]], [[x1, y0], [x1, y1]], [[x1, y1], [x0, y1]], [[x0, y1], [x0, y0]]];
|
|
383
|
+
return edges.some(([c, d]) => segmentIntersection(a, b, c, d, true));
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
function lineNumberLabel(lineObj) {
|
|
387
|
+
if (!lineObj.number) return "";
|
|
388
|
+
const size = PID.text.line;
|
|
389
|
+
const candidates = [];
|
|
390
|
+
const segs = lineObj.pieces.flatMap((p) => segmentsOf(p)).map(([a, b]) => [a, b, dist(a, b)]).sort((p, q) => q[2] - p[2]);
|
|
391
|
+
for (const [a, b, len] of segs) {
|
|
392
|
+
if (len < textWidthEm(lineObj.number) * size + 4) continue;
|
|
393
|
+
const horizontal = Math.abs(a[1] - b[1]) < 1e-6;
|
|
394
|
+
for (const f of [0.5, 0.3, 0.7, 0.2, 0.8]) {
|
|
395
|
+
const mx = a[0] + (b[0] - a[0]) * f;
|
|
396
|
+
const my = a[1] + (b[1] - a[1]) * f;
|
|
397
|
+
if (horizontal) {
|
|
398
|
+
candidates.push({ x: mx, y: my - 1.3, anchor: "middle" }, { x: mx, y: my + size + 0.9, anchor: "middle" });
|
|
399
|
+
} else {
|
|
400
|
+
candidates.push({ x: mx - 1.3, y: my, anchor: "middle", rotate: -90 }, { x: mx + size + 0.9, y: my, anchor: "middle", rotate: -90 });
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
if (!candidates.length) {
|
|
405
|
+
warnings.push(`line ${lineObj.id} (${lineObj.number}) has no straight run long enough for its number`);
|
|
406
|
+
return "";
|
|
407
|
+
}
|
|
408
|
+
return placeLabel({ text: lineObj.number, size, candidates, owner: lineObj.id, extra: { "data-label": "line-number", "data-for-line": lineObj.id } });
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function equipmentLabel(sym) {
|
|
412
|
+
const text = style === "pid" ? sym.tag : sym.label ?? sym.tag;
|
|
413
|
+
if (!text || /^(?:instrument|valve|relief|flow|line|terminal|logic)\./u.test(sym.kind)) return "";
|
|
414
|
+
const size = style === "pid" ? PID.text.equipment : labelSize;
|
|
415
|
+
const [x0, y0, x1, y1] = sym.box;
|
|
416
|
+
const cx = (x0 + x1) / 2;
|
|
417
|
+
const cy = (y0 + y1) / 2;
|
|
418
|
+
const candidates = [
|
|
419
|
+
{ x: cx, y: y1 + size + 1.2, anchor: "middle" },
|
|
420
|
+
{ x: cx, y: y0 - 1.4, anchor: "middle" },
|
|
421
|
+
{ x: x1 + 1.6, y: cy + size * 0.35, anchor: "start" },
|
|
422
|
+
{ x: x0 - 1.6, y: cy + size * 0.35, anchor: "end" },
|
|
423
|
+
{ x: x1 + 1.6, y: y1, anchor: "start" },
|
|
424
|
+
{ x: x0 - 1.6, y: y1, anchor: "end" },
|
|
425
|
+
...[0.3, 0.7, 0.15, 0.85].flatMap((f) => [
|
|
426
|
+
{ x: x1 + 1.6, y: y0 + (y1 - y0) * f + size * 0.35, anchor: "start" },
|
|
427
|
+
{ x: x0 - 1.6, y: y0 + (y1 - y0) * f + size * 0.35, anchor: "end" },
|
|
428
|
+
]),
|
|
429
|
+
];
|
|
430
|
+
return placeLabel({ text, size, candidates, owner: sym.id, bold: style === "pid", extra: { "data-label": "equipment-tag", "data-for-sym": sym.id } });
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function valveTagLabel(sym) {
|
|
434
|
+
if (!sym.tag || !(sym.kind.startsWith("valve.") || sym.kind.startsWith("relief.") || sym.kind.startsWith("flow."))) return "";
|
|
435
|
+
const size = PID.text.small;
|
|
436
|
+
const [x0, y0, x1, y1] = sym.box;
|
|
437
|
+
const cx = (x0 + x1) / 2;
|
|
438
|
+
const cy = (y0 + y1) / 2;
|
|
439
|
+
const candidates = [
|
|
440
|
+
{ x: cx, y: y1 + size + 0.8, anchor: "middle" },
|
|
441
|
+
{ x: cx, y: y0 - 0.9, anchor: "middle" },
|
|
442
|
+
{ x: x1 + 1.2, y: cy + size * 0.35, anchor: "start" },
|
|
443
|
+
{ x: x0 - 1.2, y: cy + size * 0.35, anchor: "end" },
|
|
444
|
+
];
|
|
445
|
+
return placeLabel({ text: sym.tag, size, candidates, owner: sym.id, extra: { "data-label": "valve-tag", "data-for-sym": sym.id } });
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function lineStroke(lineObj) {
|
|
449
|
+
if (style === "pid") return PID.ink;
|
|
450
|
+
if (lineObj.color) return lineObj.color;
|
|
451
|
+
if (lineObj.role && STREAM[lineObj.role]) return STREAM[lineObj.role].color;
|
|
452
|
+
return PID.ink;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function renderLines() {
|
|
456
|
+
const out = [];
|
|
457
|
+
const drawnProcess = [];
|
|
458
|
+
for (const lineObj of lines) finalizePieces(lineObj);
|
|
459
|
+
for (const lineObj of lines) {
|
|
460
|
+
const spec = LINE_KINDS[lineObj.kind];
|
|
461
|
+
const stroke = lineStroke(lineObj);
|
|
462
|
+
const dash = lineObj.role && STREAM[lineObj.role]?.dash && style !== "pid" ? STREAM[lineObj.role].dash : spec.dash;
|
|
463
|
+
const width = lineObj.kind === "stream" && lineObj.width ? lineObj.width : spec.width;
|
|
464
|
+
for (const piece of lineObj.pieces) {
|
|
465
|
+
const d = isProcess(lineObj.kind) ? hopped(piece, drawnProcess) : pathOf(piece);
|
|
466
|
+
// A wave signal keeps its route as an unstroked carrier; the wave itself is the mark.
|
|
467
|
+
out.push(el("path", {
|
|
468
|
+
d, fill: "none", stroke: isWave(lineObj.kind) ? "none" : stroke, "stroke-width": width, "stroke-dasharray": dash, "stroke-linejoin": "round", "stroke-linecap": "butt",
|
|
469
|
+
"data-line": lineObj.kind, "data-line-id": lineObj.id, "data-role": lineObj.role ?? undefined, "data-number": lineObj.number ?? undefined,
|
|
470
|
+
}));
|
|
471
|
+
out.push(lineMarks(piece, lineObj.kind, stroke));
|
|
472
|
+
}
|
|
473
|
+
out.push(arrowsFor(lineObj, stroke));
|
|
474
|
+
if (isProcess(lineObj.kind)) for (const piece of lineObj.pieces) drawnProcess.push(...segmentsOf(piece));
|
|
475
|
+
}
|
|
476
|
+
for (const [x, y] of junctions) out.push(el("circle", { cx: x, cy: y, r: 0.65, fill: PID.ink, stroke: "none", "data-junction": "1" }));
|
|
477
|
+
return out.join("");
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function render() {
|
|
481
|
+
labels.length = 0;
|
|
482
|
+
labels.push(...fixedLabels);
|
|
483
|
+
const lineSvg = renderLines();
|
|
484
|
+
const symbolSvg = symbols.map((s) => s.svg()).join("");
|
|
485
|
+
const tagSvg = [
|
|
486
|
+
...symbols.map(equipmentLabel),
|
|
487
|
+
...symbols.map(valveTagLabel),
|
|
488
|
+
...lines.map(lineNumberLabel),
|
|
489
|
+
].join("");
|
|
490
|
+
return `<g data-canvas="${esc(style)}" data-canvas-box="${box.map(n).join(" ")}">${lineSvg}${symbolSvg}${tagSvg}${extras.join("")}</g>`;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
return {
|
|
494
|
+
box,
|
|
495
|
+
style,
|
|
496
|
+
symbols,
|
|
497
|
+
lines,
|
|
498
|
+
warnings,
|
|
499
|
+
add,
|
|
500
|
+
inline,
|
|
501
|
+
pipe: (opts) => buildLine({ kind: opts.kind ?? "process-major", ...opts }),
|
|
502
|
+
signal: (opts) => buildLine({ kind: opts.type ? `signal-${SIGNAL_ALIASES[opts.type] ?? opts.type}` : "signal-electric", arrows: false, ...opts }),
|
|
503
|
+
connect: (opts) => buildLine({ kind: "connection", arrows: false, ...opts }),
|
|
504
|
+
stream: (opts) => buildLine({ kind: "stream", ...opts }),
|
|
505
|
+
text: (x, y, text, { size = PID.text.label, anchor = "start", bold = false, color = PID.ink, kind = "note" } = {}) => {
|
|
506
|
+
const b = textBox(x, y, text, size, anchor);
|
|
507
|
+
fixedLabels.push({ box: b, owner: "text" });
|
|
508
|
+
extras.push(textEl(x, y, text, size, { "text-anchor": anchor, "font-weight": bold ? "bold" : undefined, fill: color, "data-label": kind }));
|
|
509
|
+
},
|
|
510
|
+
raw: (svg) => extras.push(svg),
|
|
511
|
+
streamNumbers: () => extras.some((x) => x.includes("data-stream-number")),
|
|
512
|
+
// PFD stream number: a diamond on a straight run of the line with the number inside.
|
|
513
|
+
streamNumber: (lineObj, number, { at = 0.5 } = {}) => {
|
|
514
|
+
const total = pathLength(lineObj.points);
|
|
515
|
+
let s = at * total;
|
|
516
|
+
const segs = [];
|
|
517
|
+
let s0 = 0;
|
|
518
|
+
for (const [a, b] of segmentsOf(lineObj.points)) { const len = dist(a, b); segs.push([s0, s0 + len]); s0 += len; }
|
|
519
|
+
const fits = segs.filter(([lo, hi]) => hi - lo > 12);
|
|
520
|
+
if (!fits.length) throw new Error(`streamNumber: ${lineObj.id} has no straight run of 12 mm for the marker`);
|
|
521
|
+
const best = fits.reduce((p, q) => (Math.abs((p[0] + p[1]) / 2 - s) < Math.abs((q[0] + q[1]) / 2 - s) ? p : q));
|
|
522
|
+
s = Math.min(best[1] - 6, Math.max(best[0] + 6, s));
|
|
523
|
+
const { point: [x, y] } = pointAlong(lineObj.points, s);
|
|
524
|
+
const r = 3.2;
|
|
525
|
+
const size = style === "pid" ? PID.text.small : labelSize;
|
|
526
|
+
fixedLabels.push({ box: [x - r, y - r, x + r, y + r], owner: "stream" });
|
|
527
|
+
extras.push(el("g", { "data-stream-number": String(number), "data-for-line": lineObj.id },
|
|
528
|
+
el("path", { d: pathOf([[x, y - r], [x + r, y], [x, y + r], [x - r, y]], true), fill: "#ffffff", stroke: PID.ink, "stroke-width": W.symbol, "data-line-mark": "stream-number" }),
|
|
529
|
+
textEl(x, y + size * 0.36, String(number), size, { "text-anchor": "middle", "data-label": "stream-number" })));
|
|
530
|
+
},
|
|
531
|
+
// A stream table: one column per stream number, one row per property, inside `box`.
|
|
532
|
+
streamTable: ({ box: [x0, y0, x1], streams, rows, title = "STREAM TABLE" }) => {
|
|
533
|
+
const size = style === "pid" ? PID.text.small : labelSize;
|
|
534
|
+
const rowH = size + 2;
|
|
535
|
+
const headW = Math.max(...rows.map((r) => textWidthEm(r.label) * size)) + 3;
|
|
536
|
+
const colW = (x1 - x0 - headW) / streams.length;
|
|
537
|
+
const out = [textEl(x0, y0 + size, title, size, { "font-weight": "bold", "data-label": "stream-table" })];
|
|
538
|
+
const top = y0 + size + 1.5;
|
|
539
|
+
const cell = (cx, cy, w, text, bold = false) => {
|
|
540
|
+
const within = [cx, cy, cx + w, cy + rowH];
|
|
541
|
+
out.push(el("path", { d: pathOf([[cx, cy], [cx + w, cy], [cx + w, cy + rowH], [cx, cy + rowH]], true), fill: "none", stroke: PID.ink, "stroke-width": W.detail }));
|
|
542
|
+
out.push(textEl(cx + 1, cy + rowH - 1.1, text, size, { "data-label": "stream-table", "data-within": within.map(n).join(" "), "font-weight": bold ? "bold" : undefined }));
|
|
543
|
+
};
|
|
544
|
+
cell(x0, top, headW, "Stream", true);
|
|
545
|
+
streams.forEach((st, i) => cell(x0 + headW + i * colW, top, colW, String(st), true));
|
|
546
|
+
rows.forEach((row, j) => {
|
|
547
|
+
cell(x0, top + (j + 1) * rowH, headW, row.label);
|
|
548
|
+
row.values.forEach((v, i) => cell(x0 + headW + i * colW, top + (j + 1) * rowH, colW, String(v)));
|
|
549
|
+
});
|
|
550
|
+
const y1 = top + (rows.length + 1) * rowH;
|
|
551
|
+
fixedLabels.push({ box: [x0, y0, x1, y1], owner: "table" });
|
|
552
|
+
extras.push(el("g", { "data-stream-table": "1" }, out));
|
|
553
|
+
return y1;
|
|
554
|
+
},
|
|
555
|
+
// A dashed boundary around equipment that shares an enclosure (oven, cold box, skid, fume
|
|
556
|
+
// hood). It is not a symbol: lines may cross it, and its name sits inside its top-left corner.
|
|
557
|
+
zone: ([x0, y0, x1, y1], name, { color = "#555555" } = {}) => {
|
|
558
|
+
zoneSegments.push(...segmentsOf([[x0, y0], [x1, y0], [x1, y1], [x0, y1], [x0, y0]]));
|
|
559
|
+
extras.push(el("path", { d: pathOf([[x0, y0], [x1, y0], [x1, y1], [x0, y1]], true), fill: "none", stroke: color, "stroke-width": 0.25, "stroke-dasharray": "1.6 0.9", "data-zone": name }));
|
|
560
|
+
if (name) {
|
|
561
|
+
const size = style === "pid" ? PID.text.small : labelSize;
|
|
562
|
+
fixedLabels.push({ box: textBox(x0 + 1.2, y0 + size + 0.8, name, size, "start"), owner: "zone" });
|
|
563
|
+
extras.push(textEl(x0 + 1.2, y0 + size + 0.8, name, size, { "data-label": "zone", fill: color }));
|
|
564
|
+
}
|
|
565
|
+
},
|
|
566
|
+
render,
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// ------------------------------------------------------------------ sheet
|
|
571
|
+
|
|
572
|
+
// A legend sample of any kind the library draws, built at (x, y). Composed kinds (a valve body
|
|
573
|
+
// with its actuator, an instrument function at a location) are rebuilt from their names, so
|
|
574
|
+
// the legend always shows the symbol as drawn.
|
|
575
|
+
export function legendSample(kind, x, y) {
|
|
576
|
+
const fixed = {
|
|
577
|
+
"vessel.vertical": () => pid.vessel({ x, y, w: 5, h: 8 }),
|
|
578
|
+
"vessel.reactor": () => pid.vessel({ x, y: y + 1, w: 5, h: 7, agitator: "turbine", jacket: true }),
|
|
579
|
+
"vessel.jacketed": () => pid.vessel({ x, y, w: 5, h: 8, jacket: true }),
|
|
580
|
+
"vessel.horizontal": () => pid.drum({ x, y, w: 9, h: 4.4, saddles: false }),
|
|
581
|
+
"vessel.column": () => pid.vessel({ x, y, w: 4, h: 9, trays: 3, kind: "vessel.column" }),
|
|
582
|
+
"vessel.tank": () => pid.tank({ x, y, w: 7, h: 7 }),
|
|
583
|
+
"gas.cylinder": () => pid.gasCylinder({ x, y: y + 0.8, w: 3.4, h: 7 }),
|
|
584
|
+
"pump.centrifugal": () => pid.pump({ x, y, r: 2.6 }),
|
|
585
|
+
"pump.positive": () => pid.pump({ x, y, r: 2.6, type: "positive" }),
|
|
586
|
+
"pump.metering": () => pid.pump({ x, y, r: 2.6, type: "metering" }),
|
|
587
|
+
"compressor.centrifugal": () => pid.compressor({ x, y, r: 3.2 }),
|
|
588
|
+
"compressor.reciprocating": () => pid.compressor({ x, y, r: 3.2, type: "reciprocating" }),
|
|
589
|
+
"exchanger.general": () => pid.exchanger({ x, y, r: 3.2 }),
|
|
590
|
+
"exchanger.shelltube": () => pid.shellTube({ x, y, w: 12, h: 4.4 }),
|
|
591
|
+
"exchanger.floating": () => pid.shellTube({ x, y, w: 12, h: 4.4, type: "floating" }),
|
|
592
|
+
"exchanger.utube": () => pid.shellTube({ x, y, w: 12, h: 4.4, type: "utube" }),
|
|
593
|
+
"exchanger.plate": () => pid.plateExchanger({ x, y, w: 9, h: 5 }),
|
|
594
|
+
"exchanger.aircooler": () => pid.airCooler({ x, y: y - 3, w: 12, h: 3.4 }),
|
|
595
|
+
"package.unit": () => pid.unit({ x, y, w: 9, h: 6 }),
|
|
596
|
+
"drive.motor": () => pid.motor({ x, y, r: 2.6 }),
|
|
597
|
+
"valve.angle": () => pid.angleValve({ x: x - 1.5, y: y - 1.5, inlet: "down", outlet: "right" }),
|
|
598
|
+
"valve.regulator": () => pid.regulator({ x, y: y + 2 }),
|
|
599
|
+
"valve.backpressure": () => pid.regulator({ x, y: y + 2, backPressure: true }),
|
|
600
|
+
"valve.safety": () => pid.safetyValve({ x, y: y + 1 }),
|
|
601
|
+
"relief.rupture": () => pid.ruptureDisk({ x, y, orient: "right" }),
|
|
602
|
+
"flow.orifice": () => pid.orifice({ x, y }),
|
|
603
|
+
"flow.rotameter": () => pid.rotameter({ x, y, orient: "right" }),
|
|
604
|
+
"flow.mfc": () => pid.mfc({ x, y }),
|
|
605
|
+
"line.filter": () => pid.filter({ x, y }),
|
|
606
|
+
"line.reducer": () => pid.reducer({ x, y }),
|
|
607
|
+
"line.reducer-eccentric": () => pid.reducer({ x, y, eccentric: true }),
|
|
608
|
+
"line.flange": () => pid.flange({ x, y }),
|
|
609
|
+
"line.sightglass": () => pid.sightGlass({ x, y }),
|
|
610
|
+
"line.blind": () => pid.blind({ x, y: y + 2.5 }),
|
|
611
|
+
"line.blind-open": () => pid.blind({ x, y: y + 2.5, state: "open" }),
|
|
612
|
+
"logic.interlock": () => pid.interlock({ x, y, r: 3 }),
|
|
613
|
+
"terminal.vent": () => pid.vent({ x: x - 2, y: y + 2.6, text: "" }),
|
|
614
|
+
"terminal.drain": () => pid.drain({ x, y: y - 2, text: "" }),
|
|
615
|
+
"terminal.sample": () => pid.samplePoint({ x: x - 1, y: y - 2.4, text: "" }),
|
|
616
|
+
"terminal.offpage": () => pid.offPage({ x: x - 6, y, text: "" }),
|
|
617
|
+
"terminal.utility": () => pid.utility({ x: x - 5, y, text: "" }),
|
|
618
|
+
}[kind];
|
|
619
|
+
if (fixed) return fixed();
|
|
620
|
+
const body = /^valve\.([a-z]+)(?:\.([a-z]+))?$/u.exec(kind);
|
|
621
|
+
if (body) {
|
|
622
|
+
const [, type, actuator = null] = body;
|
|
623
|
+
if (type === "angle") return pid.angleValve({ x: x - 1.5, y: y + 1, inlet: "down", outlet: "right", actuator });
|
|
624
|
+
return pid.valve({ x, y: y + (actuator ? 2.5 : type === "needle" ? 1 : type === "threeway" ? -1 : 0), type, actuator });
|
|
625
|
+
}
|
|
626
|
+
const flow = /^flow\.([a-z]+)$/u.exec(kind);
|
|
627
|
+
if (flow) return pid.flowElement({ x, y, type: flow[1] });
|
|
628
|
+
const bubble = /^instrument\.([a-z]+)-([a-z-]+)$/u.exec(kind);
|
|
629
|
+
if (bubble) return pid.instrument({ x, y, tag: "TI-1", r: 3, fn: bubble[1], location: bubble[2] });
|
|
630
|
+
return null;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Lettering that belongs to one drawn item (tag, loop number, set pressure, fail position) is
|
|
634
|
+
// not part of the symbol, so a legend sample drops it and keeps letters that are (M, S, MAG).
|
|
635
|
+
const ITEM_TEXT = /<text\b[^>]*data-label="(?:tag-letters|tag-loop|fail|set)"[^>]*>[^<]*<\/text>/gu;
|
|
636
|
+
|
|
637
|
+
export function legendGlyph(kind, x, y, { maxW = 13, maxH = 10 } = {}) {
|
|
638
|
+
const sym = legendSample(kind, x, y);
|
|
639
|
+
if (!sym) return null;
|
|
640
|
+
// A legend glyph is a picture of a symbol, not a symbol on the drawing: rename its markers,
|
|
641
|
+
// drop item lettering and scale it into its cell.
|
|
642
|
+
const [x0, y0, x1, y1] = sym.box;
|
|
643
|
+
const k = Math.min(1, maxW / (x1 - x0), maxH / (y1 - y0));
|
|
644
|
+
const cx = (x0 + x1) / 2;
|
|
645
|
+
const cy = (y0 + y1) / 2;
|
|
646
|
+
const inner = sym.svg()
|
|
647
|
+
.replace(/ data-sym="([^"]+)"/u, ' data-legend-glyph="$1"')
|
|
648
|
+
.replace(/ data-sym-id="[^"]*"| data-tag="[^"]*"| data-box="[^"]*"| data-ports="[^"]*"/gu, "")
|
|
649
|
+
.replace(ITEM_TEXT, "")
|
|
650
|
+
// Letters that would print under the sheet minimum once scaled are left out.
|
|
651
|
+
.replace(/<text\b[^>]*font-size="([\d.]+)"[^>]*>[^<]*<\/text>/gu, (text, size) => (Number(size) * k < PID.minTextMm ? "" : text))
|
|
652
|
+
// Keep every glyph stroke printable after scaling.
|
|
653
|
+
.replace(/ stroke-width="([\d.]+)"/gu, (_, w) => ` stroke-width="${n(Math.max(Number(w) * k, 0.12) / k)}"`);
|
|
654
|
+
return { svg: el("g", { transform: `translate(${n(x - cx * k)} ${n(y - cy * k)}) scale(${n(k)})` }, inner), h: (y1 - y0) * k };
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
export function legendLine(kind, x0, x1, y) {
|
|
658
|
+
const spec = LINE_KINDS[kind];
|
|
659
|
+
const points = [[x0, y], [x1, y]];
|
|
660
|
+
const carrier = el("path", { d: pathOf(points), fill: "none", stroke: isWave(kind) ? "none" : PID.ink, "stroke-width": spec.width, "stroke-dasharray": spec.dash, "data-legend-line": kind });
|
|
661
|
+
return carrier + lineMarks(points, kind, PID.ink, { first: (x1 - x0) / 2, every: x1 - x0 }).replace(" data-line-mark=", " data-legend-mark=");
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
export function createSheet({
|
|
665
|
+
mode = "pid", size = "A3", title, subtitle = "", number = "", rev = "0", date = "", scale = "NTS", sheet = "1 OF 1",
|
|
666
|
+
project = "", owner = "", drawn = "", checked = "", approved = "", lang = "en", notes = [], legendTitle = "LEGEND",
|
|
667
|
+
legendColumn = true, headerBand = true,
|
|
668
|
+
} = {}) {
|
|
669
|
+
if (!title) throw new Error("createSheet: title is required");
|
|
670
|
+
const [width, height] = SHEETS[size] ?? size;
|
|
671
|
+
const frame = [20, 10, width - 10, height - 10];
|
|
672
|
+
const legendWidth = legendColumn ? 74 : 0;
|
|
673
|
+
const titleBlock = [frame[2] - 180, frame[3] - 38, frame[2], frame[3]];
|
|
674
|
+
const legendBox = [frame[2] - legendWidth, frame[1], frame[2], titleBlock[1]];
|
|
675
|
+
const headerBox = [frame[0], frame[1], legendBox[0], frame[1] + (headerBand ? 26 : 0)];
|
|
676
|
+
const notesBox = [frame[0], titleBlock[1], titleBlock[0], frame[3]];
|
|
677
|
+
const drawing = [frame[0] + 4, headerBox[3] + 3, legendBox[0] - 4, titleBlock[1] - 3];
|
|
678
|
+
const canvas = createCanvas({ box: drawing, style: "pid" });
|
|
679
|
+
const info = [];
|
|
680
|
+
|
|
681
|
+
function equipmentInfo(sym, specLines = []) {
|
|
682
|
+
info.push({ sym, lines: specLines });
|
|
683
|
+
return sym;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function header() {
|
|
687
|
+
if (!headerBand) return "";
|
|
688
|
+
const out = [el("path", { d: pathOf([[headerBox[0], headerBox[3]], [headerBox[2], headerBox[3]]]), stroke: PID.ink, "stroke-width": W.symbol, fill: "none", "data-frame": "header" })];
|
|
689
|
+
const blocks = info.map(({ sym, lines }) => {
|
|
690
|
+
const rows = [
|
|
691
|
+
{ text: sym.tag ?? "", size: PID.text.equipment, bold: true },
|
|
692
|
+
{ text: sym.label ?? "", size: PID.text.label },
|
|
693
|
+
...lines.map((t) => ({ text: t, size: PID.text.small })),
|
|
694
|
+
].filter((r) => r.text);
|
|
695
|
+
const w = Math.max(...rows.map((r) => textWidthEm(r.text) * r.size)) + 2;
|
|
696
|
+
return { sym, rows, w, cx: (sym.box[0] + sym.box[2]) / 2 };
|
|
697
|
+
}).sort((a, b) => a.cx - b.cx);
|
|
698
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
699
|
+
const b = blocks[i];
|
|
700
|
+
const left = i ? blocks[i - 1].cx + blocks[i - 1].w / 2 + 3 : headerBox[0] + 2;
|
|
701
|
+
b.cx = Math.max(b.cx, left + b.w / 2);
|
|
702
|
+
}
|
|
703
|
+
for (let i = blocks.length - 1; i >= 0; i--) {
|
|
704
|
+
const b = blocks[i];
|
|
705
|
+
const right = i < blocks.length - 1 ? blocks[i + 1].cx - blocks[i + 1].w / 2 - 3 : headerBox[2] - 2;
|
|
706
|
+
b.cx = Math.min(b.cx, right - b.w / 2);
|
|
707
|
+
}
|
|
708
|
+
for (const b of blocks) {
|
|
709
|
+
let y = headerBox[1] + 5;
|
|
710
|
+
const g = [];
|
|
711
|
+
for (const r of b.rows) {
|
|
712
|
+
g.push(textEl(b.cx, y, r.text, r.size, { "text-anchor": "middle", "font-weight": r.bold ? "bold" : undefined, "data-label": "equipment-info", "data-within": headerBox.map(n).join(" ") }));
|
|
713
|
+
y += r.size + 1.1;
|
|
714
|
+
}
|
|
715
|
+
out.push(el("g", { "data-equipment-info": b.sym.tag ?? b.sym.id }, g));
|
|
716
|
+
}
|
|
717
|
+
return out.join("");
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
const instrumentLetters = () => [...new Set(canvas.symbols.filter((s) => s.kind.startsWith("instrument.")).map((s) => s.tag.split("-")[0]))].sort();
|
|
721
|
+
const tagItem = (letters, x, y, gap, within) => {
|
|
722
|
+
const parsed = parseInstrumentTag(`${letters}-1`);
|
|
723
|
+
return el("g", { "data-legend-item": `tag:${letters}` },
|
|
724
|
+
textEl(x, y, letters, PID.text.small, { "font-weight": "bold", "data-label": "legend", "data-within": within }),
|
|
725
|
+
textEl(x + gap, y, parsed.ok ? parsed.meaning : "", PID.text.small, { "data-label": "legend", "data-within": within }));
|
|
726
|
+
};
|
|
727
|
+
|
|
728
|
+
// The legend column: line classes, then every symbol kind drawn, then (when it fits) the
|
|
729
|
+
// instrument identification letters. Returns how far it runs past its column.
|
|
730
|
+
function legend({ withTags = true } = {}) {
|
|
731
|
+
if (!legendColumn) return { svg: "", over: 0 };
|
|
732
|
+
const kinds = [...new Set(canvas.symbols.map((s) => s.kind))].sort();
|
|
733
|
+
const lineKinds = [...new Set(canvas.lines.map((l) => l.kind))];
|
|
734
|
+
const tags = withTags ? instrumentLetters() : [];
|
|
735
|
+
const out = [];
|
|
736
|
+
const [x0, y0, x1, y1] = legendBox;
|
|
737
|
+
let y = y0 + 6;
|
|
738
|
+
const within = legendBox.map(n).join(" ");
|
|
739
|
+
out.push(textEl(x0 + 3, y, legendTitle, PID.text.label + 0.5, { "font-weight": "bold", "data-label": "legend-title", "data-within": within }));
|
|
740
|
+
y += 3;
|
|
741
|
+
// Each row is as tall as its sample needs; the text sits centred on the sample.
|
|
742
|
+
const row = (key, draw, text, sampleHeight = 4) => {
|
|
743
|
+
const lines = wrap(text, x1 - x0 - 20, PID.text.small);
|
|
744
|
+
const step = Math.max(6.2, sampleHeight + 2, lines.length * 2.5 + 1.6);
|
|
745
|
+
const cy = y + step / 2;
|
|
746
|
+
out.push(el("g", { "data-legend-item": key }, draw(cy), lines.map((t, i) => textEl(x0 + 18, cy + 0.8 + (i - (lines.length - 1) / 2) * 2.5, t, PID.text.small, { "data-label": "legend", "data-within": within }))));
|
|
747
|
+
y += step;
|
|
748
|
+
};
|
|
749
|
+
for (const kind of lineKinds) row(`line:${kind}`, (cy) => legendLine(kind, x0 + 3, x0 + 15, cy), LINE_KINDS[kind].label, 3);
|
|
750
|
+
if (canvas.streamNumbers()) row("stream-number", (cy) => el("path", { d: pathOf([[x0 + 9, cy - 2.4], [x0 + 11.4, cy], [x0 + 9, cy + 2.4], [x0 + 6.6, cy]], true), fill: "#ffffff", stroke: PID.ink, "stroke-width": W.symbol, "data-legend-mark": "stream-number" }), "Stream number", 4.8);
|
|
751
|
+
for (const kind of kinds) {
|
|
752
|
+
const probe = legendGlyph(kind, 0, 0);
|
|
753
|
+
row(kind, (cy) => legendGlyph(kind, x0 + 9, cy)?.svg ?? "", pid.describeKind(kind, { short: true }) ?? kind, probe?.h ?? 4);
|
|
754
|
+
}
|
|
755
|
+
if (tags.length) {
|
|
756
|
+
y += 5;
|
|
757
|
+
out.push(textEl(x0 + 3, y, "INSTRUMENT IDENTIFICATION", PID.text.small, { "font-weight": "bold", "data-label": "legend-title", "data-within": within }));
|
|
758
|
+
for (const letters of tags) {
|
|
759
|
+
y += 3.2;
|
|
760
|
+
out.push(tagItem(letters, x0 + 3, y, 13, within));
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
return { svg: out.join(""), over: y - (y1 - 2) };
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// Numbered notes; when the legend column is full, the instrument identification letters
|
|
767
|
+
// take the right half of this block in two columns.
|
|
768
|
+
function noteBlock({ tags = [] } = {}) {
|
|
769
|
+
if (!notes.length && !tags.length) return "";
|
|
770
|
+
const [x0, y0, x1] = notesBox;
|
|
771
|
+
const within = notesBox.map(n).join(" ");
|
|
772
|
+
const split = tags.length ? x0 + (x1 - x0) * 0.48 : x1;
|
|
773
|
+
const out = [];
|
|
774
|
+
if (tags.length) {
|
|
775
|
+
const tx = split + 3;
|
|
776
|
+
const colW = (x1 - tx - 2) / 2;
|
|
777
|
+
const rows = Math.ceil(tags.length / 2);
|
|
778
|
+
out.push(textEl(tx, y0 + 5, "INSTRUMENT IDENTIFICATION", PID.text.label, { "font-weight": "bold", "data-label": "legend-title", "data-within": within }));
|
|
779
|
+
tags.forEach((letters, i) => out.push(tagItem(letters, tx + Math.floor(i / rows) * colW, y0 + 9 + (i % rows) * 2.9, 9, within)));
|
|
780
|
+
if (y0 + 9 + (rows - 1) * 2.9 > notesBox[3] - 1) canvas.warnings.push("the instrument identification table runs past the notes block");
|
|
781
|
+
}
|
|
782
|
+
if (!notes.length) return out.join("");
|
|
783
|
+
out.push(textEl(x0 + 3, y0 + 5, "NOTES", PID.text.label, { "font-weight": "bold", "data-label": "notes", "data-within": within }));
|
|
784
|
+
let y = y0 + 9;
|
|
785
|
+
notes.forEach((note, i) => {
|
|
786
|
+
const lines = wrap(`${i + 1}. ${note}`, split - x0 - 8, PID.text.small);
|
|
787
|
+
for (const t of lines) {
|
|
788
|
+
out.push(textEl(x0 + 3, y, t, PID.text.small, { "data-label": "notes", "data-within": within }));
|
|
789
|
+
y += 2.7;
|
|
790
|
+
}
|
|
791
|
+
});
|
|
792
|
+
if (y > notesBox[3] + 1) canvas.warnings.push("the notes run past their block; shorten them");
|
|
793
|
+
return out.join("");
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function legendAndNotes() {
|
|
797
|
+
let column = legend();
|
|
798
|
+
let tags = [];
|
|
799
|
+
if (column.over > 0 && instrumentLetters().length) {
|
|
800
|
+
const without = legend({ withTags: false });
|
|
801
|
+
if (without.over < column.over) { column = without; tags = instrumentLetters(); }
|
|
802
|
+
}
|
|
803
|
+
if (column.over > 0) canvas.warnings.push(`the legend runs ${n(column.over)} mm past its column; use a larger sheet or fewer symbol kinds`);
|
|
804
|
+
return [column.svg, noteBlock({ tags })];
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function titleBlockSvg() {
|
|
808
|
+
const [x0, y0, x1, y1] = titleBlock;
|
|
809
|
+
const cells = [];
|
|
810
|
+
const cell = (field, box, caption, value, valueSize) => {
|
|
811
|
+
const within = box.map(n).join(" ");
|
|
812
|
+
const lines = [];
|
|
813
|
+
let sizeNow = valueSize;
|
|
814
|
+
let wrapped = wrap(value, box[2] - box[0] - 3, sizeNow);
|
|
815
|
+
while (wrapped.length > Math.max(1, Math.floor((box[3] - box[1] - 4) / (sizeNow + 0.8))) && sizeNow > PID.text.small) {
|
|
816
|
+
sizeNow -= 0.25;
|
|
817
|
+
wrapped = wrap(value, box[2] - box[0] - 3, sizeNow);
|
|
818
|
+
}
|
|
819
|
+
wrapped.forEach((t, i) => lines.push(textEl(box[0] + 1.5, box[1] + 3.6 + sizeNow + i * (sizeNow + 0.8), t, sizeNow, { "data-titleblock-value": field, "data-within": within, "font-weight": field === "title" ? "bold" : undefined })));
|
|
820
|
+
cells.push(el("g", { "data-titleblock-field": field },
|
|
821
|
+
el("path", { d: pathOf([[box[0], box[1]], [box[2], box[1]], [box[2], box[3]], [box[0], box[3]]], true), fill: "none", stroke: PID.ink, "stroke-width": W.detail }),
|
|
822
|
+
textEl(box[0] + 1.2, box[1] + 2.5, caption, 2.0, { "data-label": "titleblock-caption", "data-within": within, fill: "#444444" }),
|
|
823
|
+
lines));
|
|
824
|
+
};
|
|
825
|
+
const titleText = subtitle ? `${title}` : title;
|
|
826
|
+
cell("title", [x0, y0, x1 - 70, y0 + 18], "TITLE", titleText, 4);
|
|
827
|
+
if (subtitle) cells.push(textEl(x0 + 1.5, y0 + 16.4, subtitle, PID.text.small, { "data-label": "titleblock-subtitle", "data-within": [x0, y0, x1 - 70, y0 + 18].map(n).join(" ") }));
|
|
828
|
+
cell("number", [x1 - 70, y0, x1, y0 + 10], "DRAWING NO.", number || "-", 3.2);
|
|
829
|
+
cell("rev", [x1 - 70, y0 + 10, x1 - 46, y0 + 18], "REV", rev, 3);
|
|
830
|
+
cell("sheet", [x1 - 46, y0 + 10, x1, y0 + 18], "SHEET", sheet, 2.6);
|
|
831
|
+
cell("project", [x0, y0 + 18, x0 + 90, y0 + 28], "PROJECT", project || "-", 2.5);
|
|
832
|
+
cell("owner", [x0 + 90, y0 + 28 - 10, x1, y0 + 28], "OWNER", owner || "-", 2.5);
|
|
833
|
+
const w5 = 180 / 5;
|
|
834
|
+
[["drawn", "DRAWN", drawn], ["checked", "CHECKED", checked], ["approved", "APPROVED", approved], ["date", "DATE", date || "-"], ["scale", "SCALE", scale]].forEach(([field, caption, value], i) => {
|
|
835
|
+
cell(field, [x0 + i * w5, y0 + 28, x0 + (i + 1) * w5, y1], caption, value || "-", 2.4);
|
|
836
|
+
});
|
|
837
|
+
return el("g", { "data-titleblock": "1" }, el("path", { d: pathOf([[x0, y0], [x1, y0], [x1, y1], [x0, y1]], true), fill: "none", stroke: PID.ink, "stroke-width": W.frame }), cells);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function frameSvg() {
|
|
841
|
+
const [x0, y0, x1, y1] = frame;
|
|
842
|
+
const out = [el("path", { d: pathOf([[x0, y0], [x1, y0], [x1, y1], [x0, y1]], true), fill: "none", stroke: PID.ink, "stroke-width": W.frame, "data-frame": "border" })];
|
|
843
|
+
if (legendColumn) out.push(el("path", { d: pathOf([[legendBox[0], legendBox[1]], [legendBox[0], legendBox[3]]]), fill: "none", stroke: PID.ink, "stroke-width": W.symbol, "data-frame": "legend" }));
|
|
844
|
+
out.push(el("path", { d: pathOf([[notesBox[0], notesBox[1]], [titleBlock[0], notesBox[1]]]), fill: "none", stroke: PID.ink, "stroke-width": W.symbol, "data-frame": "notes" }));
|
|
845
|
+
const cols = Math.round((x1 - x0) / 50);
|
|
846
|
+
const rows = Math.round((y1 - y0) / 50);
|
|
847
|
+
const ticks = [];
|
|
848
|
+
for (let i = 0; i <= cols; i++) {
|
|
849
|
+
const x = x0 + ((x1 - x0) * i) / cols;
|
|
850
|
+
ticks.push(pathOf([[x, y0], [x, y0 - 4]]), pathOf([[x, y1], [x, y1 + 4]]));
|
|
851
|
+
if (i < cols) {
|
|
852
|
+
const cx = x0 + ((x1 - x0) * (i + 0.5)) / cols;
|
|
853
|
+
out.push(textEl(cx, y0 - 3.4, String(i + 1), 2.5, { "text-anchor": "middle", "data-label": "zone" }), textEl(cx, y1 + 6.6, String(i + 1), 2.5, { "text-anchor": "middle", "data-label": "zone" }));
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
for (let j = 0; j <= rows; j++) {
|
|
857
|
+
const y = y0 + ((y1 - y0) * j) / rows;
|
|
858
|
+
ticks.push(pathOf([[x0, y], [x0 - 4, y]]), pathOf([[x1, y], [x1 + 4, y]]));
|
|
859
|
+
if (j < rows) {
|
|
860
|
+
const cy = y0 + ((y1 - y0) * (j + 0.5)) / rows + 0.9;
|
|
861
|
+
const letter = String.fromCharCode(65 + j);
|
|
862
|
+
out.push(textEl(x0 - 6, cy, letter, 2.5, { "text-anchor": "middle", "data-label": "zone" }), textEl(x1 + 5, cy, letter, 2.5, { "text-anchor": "middle", "data-label": "zone" }));
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
out.push(el("path", { d: ticks.join(""), fill: "none", stroke: PID.ink, "stroke-width": W.detail, "data-frame": "zones" }));
|
|
866
|
+
return out.join("");
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
return {
|
|
870
|
+
...canvas,
|
|
871
|
+
width,
|
|
872
|
+
height,
|
|
873
|
+
frame,
|
|
874
|
+
drawing,
|
|
875
|
+
equipmentInfo,
|
|
876
|
+
render({ desc = "" } = {}) {
|
|
877
|
+
const body = [
|
|
878
|
+
el("rect", { x: 0, y: 0, width, height, fill: PID.paper, "data-frame": "paper" }),
|
|
879
|
+
frameSvg(),
|
|
880
|
+
header(),
|
|
881
|
+
canvas.render(),
|
|
882
|
+
...legendAndNotes(),
|
|
883
|
+
titleBlockSvg(),
|
|
884
|
+
].join("");
|
|
885
|
+
return svgDocument({ width, height, title: `${title}${number ? ` (${number})` : ""}`, desc, mode, lang, body });
|
|
886
|
+
},
|
|
887
|
+
};
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
export { FONT_LATIN };
|