@litfamily/lithermes 1.0.29 → 1.0.30

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.
Files changed (165) hide show
  1. package/README.md +12 -9
  2. package/README_Ko-KR.md +12 -9
  3. package/assets/lithermes-plugin/README.md +15 -2
  4. package/assets/lithermes-plugin/__init__.py +49 -10
  5. package/assets/lithermes-plugin/adhd_stack.py +228 -0
  6. package/assets/lithermes-plugin/core.py +30 -4
  7. package/assets/lithermes-plugin/core_commands.py +99 -0
  8. package/assets/lithermes-plugin/core_contexts.py +54 -4
  9. package/assets/lithermes-plugin/core_contract.py +2 -2
  10. package/assets/lithermes-plugin/core_routing.py +126 -0
  11. package/assets/lithermes-plugin/diagnostics.py +3 -2
  12. package/assets/lithermes-plugin/lit_mark.py +75 -0
  13. package/assets/lithermes-plugin/payload-version.json +470 -58
  14. package/assets/lithermes-plugin/plugin.yaml +1 -1
  15. package/assets/lithermes-plugin/scientific_visualization.py +23 -0
  16. package/assets/lithermes-plugin/skills/frontend-ui-ux/SKILL.md +7 -7
  17. package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/components.css +414 -0
  18. package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/fonts.json +42 -0
  19. package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/layouts/landing.html +164 -0
  20. package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/layouts/settings.html +114 -0
  21. package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/layouts/workspace.html +115 -0
  22. package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/styles/computational-brutalism.json +227 -0
  23. package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/styles/field-notes.json +205 -0
  24. package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/styles/graphite.json +200 -0
  25. package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/styles/meadow.json +199 -0
  26. package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/styles/neo-brutalism.json +222 -0
  27. package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/ui.js +226 -0
  28. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/README.md +2 -4
  29. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/complete-contract.md +4 -4
  30. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/craft-floor.md +10 -0
  31. package/assets/lithermes-plugin/skills/frontend-ui-ux/scripts/design-lock.mjs +400 -0
  32. package/assets/lithermes-plugin/skills/frontend-ui-ux/scripts/probe-rules.json +6 -1
  33. package/assets/lithermes-plugin/skills/frontend-ui-ux/scripts/probe.mjs +76 -4
  34. package/assets/lithermes-plugin/skills/lit-adhd/SKILL.md +194 -0
  35. package/assets/lithermes-plugin/skills/lit-adhd/references/brief-contract.md +55 -0
  36. package/assets/lithermes-plugin/skills/lit-adhd/references/card-examples.md +86 -0
  37. package/assets/lithermes-plugin/skills/lit-adhd/references/status-taxonomy.md +40 -0
  38. package/assets/lithermes-plugin/skills/lit-adhd/scripts/adhd.mjs +11 -0
  39. package/assets/lithermes-plugin/skills/lit-adhd/scripts/lib/collect.mjs +554 -0
  40. package/assets/lithermes-plugin/skills/lit-adhd/scripts/lib/gauge.mjs +89 -0
  41. package/assets/lithermes-plugin/skills/lit-adhd/scripts/lib/hermes-sessions.mjs +219 -0
  42. package/assets/lithermes-plugin/skills/lit-adhd/scripts/lib/next-step.mjs +101 -0
  43. package/assets/lithermes-plugin/skills/lit-adhd/scripts/lib/plan-task-rows.mjs +32 -0
  44. package/assets/lithermes-plugin/skills/lit-adhd/scripts/lib/redact.mjs +42 -0
  45. package/assets/lithermes-plugin/skills/lit-adhd/scripts/lib/secret-shapes.mjs +59 -0
  46. package/assets/lithermes-plugin/skills/lit-diagram-drawer/SKILL.md +7 -4
  47. package/assets/lithermes-plugin/skills/lit-diagram-drawer/references/style-guide.md +20 -11
  48. package/assets/lithermes-plugin/skills/lit-diagram-drawer/references/type-architecture.md +3 -3
  49. package/assets/lithermes-plugin/skills/lit-diagram-drawer/references/type-dependency.md +1 -1
  50. package/assets/lithermes-plugin/skills/lit-diagram-drawer/references/type-tree.md +3 -3
  51. package/assets/lithermes-plugin/skills/lit-diagram-drawer/references/type-wardley.md +1 -1
  52. package/assets/lithermes-plugin/skills/lit-diagram-drawer/references/verifier-guide.md +5 -0
  53. package/assets/lithermes-plugin/skills/lit-diagram-drawer/scripts/diagram_quality.py +4 -4
  54. package/assets/lithermes-plugin/skills/lit-docx/SKILL.md +1 -1
  55. package/assets/lithermes-plugin/skills/lit-hijack/SKILL.md +239 -0
  56. package/assets/lithermes-plugin/skills/lit-hijack/references/capture-and-provenance.md +123 -0
  57. package/assets/lithermes-plugin/skills/lit-hijack/references/clean-room-build.md +128 -0
  58. package/assets/lithermes-plugin/skills/lit-hijack/references/distil-and-licence.md +128 -0
  59. package/assets/lithermes-plugin/skills/lit-hijack/references/gap-and-port-design.md +152 -0
  60. package/assets/lithermes-plugin/skills/lit-hijack/references/parity-and-report.md +131 -0
  61. package/assets/lithermes-plugin/skills/lit-hijack/scripts/hijack.mjs +995 -0
  62. package/assets/lithermes-plugin/skills/lit-plan/SKILL.md +1 -0
  63. package/assets/lithermes-plugin/skills/lit-pptx/SKILL.md +11 -6
  64. package/assets/lithermes-plugin/skills/lit-pptx/fonts/jetbrains-mono/JetBrainsMono-Bold.ttf +0 -0
  65. package/assets/lithermes-plugin/skills/lit-pptx/fonts/jetbrains-mono/JetBrainsMono-Regular.ttf +0 -0
  66. package/assets/lithermes-plugin/skills/lit-pptx/fonts/jetbrains-mono/OFL.txt +93 -0
  67. package/assets/lithermes-plugin/skills/lit-pptx/fonts/provenance.json +1 -0
  68. package/assets/lithermes-plugin/skills/lit-pptx/references/anti-slop-checklist.md +3 -0
  69. package/assets/lithermes-plugin/skills/lit-pptx/references/authoring-guide.md +1 -1
  70. package/assets/lithermes-plugin/skills/lit-pptx/references/consensus-protocol.md +1 -1
  71. package/assets/lithermes-plugin/skills/lit-pptx/references/direction-step.md +8 -2
  72. package/assets/lithermes-plugin/skills/lit-pptx/references/examples/13-engineering-review-computational-brutalism-ko.md +175 -0
  73. package/assets/lithermes-plugin/skills/lit-pptx/references/examples/14-launch-pitch-neo-brutalism-ko.md +156 -0
  74. package/assets/lithermes-plugin/skills/lit-pptx/references/examples/15-design-lecture-bauhaus-ko.md +141 -0
  75. package/assets/lithermes-plugin/skills/lit-pptx/references/examples/16-community-programme-riso-ko.md +160 -0
  76. package/assets/lithermes-plugin/skills/lit-pptx/references/examples/17-public-data-briefing-newsroom-ko.md +183 -0
  77. package/assets/lithermes-plugin/skills/lit-pptx/references/layout-families.md +1 -1
  78. package/assets/lithermes-plugin/skills/lit-pptx/references/tonalities/bauhaus.md +105 -0
  79. package/assets/lithermes-plugin/skills/lit-pptx/references/tonalities/computational-brutalism.md +111 -0
  80. package/assets/lithermes-plugin/skills/lit-pptx/references/tonalities/neo-brutalism.md +114 -0
  81. package/assets/lithermes-plugin/skills/lit-pptx/references/tonalities/newsroom.md +106 -0
  82. package/assets/lithermes-plugin/skills/lit-pptx/references/tonalities/riso.md +101 -0
  83. package/assets/lithermes-plugin/skills/lit-pptx/scripts/craft_extras.py +49 -0
  84. package/assets/lithermes-plugin/skills/lit-pptx/scripts/deck_output.py +4 -2
  85. package/assets/lithermes-plugin/skills/lit-pptx/scripts/lib/render-pack.js +96 -10
  86. package/assets/lithermes-plugin/skills/lit-pptx/scripts/lib/template-registry.js +50 -6
  87. package/assets/lithermes-plugin/skills/lit-pptx/templates/tonalities/bauhaus/pack.yaml +103 -0
  88. package/assets/lithermes-plugin/skills/lit-pptx/templates/tonalities/computational-brutalism/pack.yaml +99 -0
  89. package/assets/lithermes-plugin/skills/lit-pptx/templates/tonalities/neo-brutalism/pack.yaml +107 -0
  90. package/assets/lithermes-plugin/skills/lit-pptx/templates/tonalities/newsroom/pack.yaml +99 -0
  91. package/assets/lithermes-plugin/skills/lit-pptx/templates/tonalities/riso/pack.yaml +99 -0
  92. package/assets/lithermes-plugin/skills/lit-recap/SKILL.md +1 -1
  93. package/assets/lithermes-plugin/skills/lit-schematic/SKILL.md +144 -0
  94. package/assets/lithermes-plugin/skills/lit-schematic/examples/iso-heat-exchanger/build.mjs +38 -0
  95. package/assets/lithermes-plugin/skills/lit-schematic/examples/iso-heat-exchanger/scene.mjs +133 -0
  96. package/assets/lithermes-plugin/skills/lit-schematic/examples/journal-adsorption-rig/build.mjs +87 -0
  97. package/assets/lithermes-plugin/skills/lit-schematic/examples/pid-dosing-skid/build.mjs +85 -0
  98. package/assets/lithermes-plugin/skills/lit-schematic/references/cutaway-outputs.md +70 -0
  99. package/assets/lithermes-plugin/skills/lit-schematic/references/isometric-craft.md +101 -0
  100. package/assets/lithermes-plugin/skills/lit-schematic/references/journal-figures.md +72 -0
  101. package/assets/lithermes-plugin/skills/lit-schematic/references/kit-api.md +134 -0
  102. package/assets/lithermes-plugin/skills/lit-schematic/references/pfd-and-layout.md +53 -0
  103. package/assets/lithermes-plugin/skills/lit-schematic/references/pid-drafting.md +103 -0
  104. package/assets/lithermes-plugin/skills/lit-schematic/references/pid-symbols.md +163 -0
  105. package/assets/lithermes-plugin/skills/lit-schematic/references/verification.md +100 -0
  106. package/assets/lithermes-plugin/skills/lit-schematic/scripts/doctor.mjs +57 -0
  107. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/figure.mjs +81 -0
  108. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/fonts.mjs +27 -0
  109. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/geom.mjs +113 -0
  110. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/interactive.mjs +292 -0
  111. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/isa.mjs +115 -0
  112. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/iso.mjs +719 -0
  113. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/legend-sheet.mjs +162 -0
  114. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/pid.mjs +937 -0
  115. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/png.mjs +132 -0
  116. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/qa-core.mjs +658 -0
  117. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/route.mjs +218 -0
  118. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/sheet.mjs +890 -0
  119. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/svg.mjs +82 -0
  120. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/theme.mjs +115 -0
  121. package/assets/lithermes-plugin/skills/lit-schematic/scripts/motion.mjs +129 -0
  122. package/assets/lithermes-plugin/skills/lit-schematic/scripts/qa.mjs +92 -0
  123. package/assets/lithermes-plugin/skills/lit-schematic/scripts/render.mjs +233 -0
  124. package/assets/lithermes-plugin/skills/lit-schematic/scripts/scaffold.mjs +60 -0
  125. package/assets/lithermes-plugin/skills/lit-scientific-visualization/SKILL.md +19 -1
  126. package/assets/lithermes-plugin/skills/lit-typographic-motion/SKILL.md +12 -11
  127. package/assets/lithermes-plugin/skills/lit-typographic-motion/bin/motion.mjs +56 -2
  128. package/assets/lithermes-plugin/skills/lit-typographic-motion/bin/runtime.mjs +129 -4
  129. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/THIRD_PARTY_NOTICES +9 -0
  130. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/blender/runner.py +218 -0
  131. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/blender.mjs +154 -0
  132. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/board.mjs +132 -0
  133. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/completion.mjs +48 -5
  134. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/gate.mjs +13 -0
  135. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/house.mjs +44 -0
  136. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/scaffold.mjs +238 -0
  137. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/score.mjs +643 -0
  138. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/sound-dsp.mjs +21 -0
  139. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/sound.mjs +171 -0
  140. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/stage-qa.mjs +23 -3
  141. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/stage.mjs +124 -17
  142. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/styles.mjs +240 -0
  143. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/treatment.mjs +152 -10
  144. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/vendor/three/LICENSE +21 -0
  145. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/vendor/three/addons/environments/RoomEnvironment.js +144 -0
  146. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/vendor/three/addons/geometries/RoundedBoxGeometry.js +155 -0
  147. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/vendor/three/three.module.js +54571 -0
  148. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/hero-3d/expected.json +67 -0
  149. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/hero-3d/index.html +139 -0
  150. package/assets/lithermes-plugin/skills/lit-typographic-motion/references/depth.md +74 -0
  151. package/assets/lithermes-plugin/skills/lit-typographic-motion/references/studio.md +87 -0
  152. package/assets/lithermes-plugin/skills/lit-typographic-motion/references/style-bibles.md +9 -2
  153. package/assets/lithermes-plugin/skills/lit-typographic-motion/references/treatment.md +24 -8
  154. package/assets/lithermes-plugin/skills/lit-typographic-motion/styles/paper.json +41 -0
  155. package/assets/lithermes-plugin/skills/lit-typographic-motion/styles/swiss-signal.json +41 -0
  156. package/assets/lithermes-plugin/skills/lit-typographic-motion/styles/terminalcore.json +42 -0
  157. package/assets/lithermes-plugin/skills/lit-typographic-motion/styles/tidal.json +41 -0
  158. package/assets/lithermes-plugin/skills/litresearch/SKILL.md +4 -0
  159. package/assets/lithermes-plugin/skills/readme-studio/SKILL.md +1 -1
  160. package/assets/lithermes-plugin/skills/review-work/SKILL.md +2 -0
  161. package/assets/lithermes-plugin/skills/start-work/SKILL.md +2 -1
  162. package/assets/lithermes-plugin/skills/visual-qa/references/complete-contract.md +3 -3
  163. package/package.json +1 -1
  164. package/readme-assets/badge-version.svg +1 -1
  165. package/src/lib/skillPayload.js +1 -1
@@ -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
+ }
@@ -0,0 +1,115 @@
1
+ // Instrument and equipment tag grammar after ANSI/ISA-5.1 identification letters.
2
+ // The parser is deliberately strict about letter positions and lenient about loop numbers,
3
+ // because projects number loops in many ways (101, 1-101, 101A) but letters carry meaning.
4
+
5
+ export const MEASURED = Object.freeze({
6
+ A: "analysis", B: "burner, combustion", C: "conductivity", D: "density", E: "voltage",
7
+ F: "flow", G: "gauging", H: "hand", I: "current", J: "power", K: "time", L: "level",
8
+ M: "moisture", N: "user's choice", O: "user's choice", P: "pressure", Q: "quantity",
9
+ R: "radiation", S: "speed, frequency", T: "temperature", U: "multivariable", V: "vibration",
10
+ W: "weight, force", X: "unclassified", Y: "event, state", Z: "position",
11
+ });
12
+
13
+ export const VARIABLE_MODIFIER = Object.freeze({
14
+ D: "differential", F: "ratio", J: "scan", K: "time rate of change", M: "momentary",
15
+ Q: "integrate, totalize", S: "safety", X: "x-axis", Y: "y-axis", Z: "z-axis",
16
+ });
17
+
18
+ export const READOUT = Object.freeze({
19
+ A: "alarm", B: "user's choice", E: "primary element", G: "glass, gauge", I: "indicating",
20
+ L: "light", N: "user's choice", O: "orifice, restriction", P: "test point", R: "recording",
21
+ W: "well, probe", X: "unclassified",
22
+ });
23
+
24
+ export const OUTPUT = Object.freeze({
25
+ C: "controlling", K: "control station", S: "switch", T: "transmitting", U: "multifunction",
26
+ V: "valve, damper", Y: "compute, convert, relay", Z: "actuator, final element",
27
+ });
28
+
29
+ export const FUNCTION_MODIFIER = Object.freeze({ H: "high", L: "low", M: "middle" });
30
+
31
+ // Pairs whose meaning is the readable name, not the letter-by-letter reading.
32
+ const NAMED = Object.freeze({
33
+ PSV: "pressure safety valve", PSE: "rupture disk", TW: "thermowell", PG: "pressure gauge",
34
+ TG: "thermometer", LG: "level gauge", FG: "sight flow glass", PCV: "self-actuated pressure regulator",
35
+ FO: "restriction orifice", HV: "hand valve", HS: "hand switch", XV: "on-off valve",
36
+ });
37
+
38
+ const TAG = /^([A-Z]{1,6})[-\s]?([0-9]{1,5}(?:[-.][0-9]{1,5})?[A-Z]?)$/u;
39
+
40
+ export function parseInstrumentTag(tag) {
41
+ const match = TAG.exec(String(tag ?? "").trim());
42
+ if (!match) return { ok: false, tag, error: "a tag is identification letters followed by a loop number, for example PIC-101" };
43
+ const [, letters, loop] = match;
44
+ const [first, ...rest] = letters;
45
+ if (!MEASURED[first]) return { ok: false, tag, error: `${first} is not a measured-variable letter` };
46
+ let index = 0;
47
+ let modifier = null;
48
+ // A second letter that is a variable modifier only counts as one when a function follows it.
49
+ if (rest.length > 1 && VARIABLE_MODIFIER[rest[0]]) {
50
+ const candidate = rest[0];
51
+ const followers = rest.slice(1);
52
+ const followersValid = followers.every((letter) => READOUT[letter] || OUTPUT[letter] || FUNCTION_MODIFIER[letter]);
53
+ if (followersValid && !(candidate === "S" && followers.every((letter) => FUNCTION_MODIFIER[letter]))) {
54
+ modifier = candidate;
55
+ index = 1;
56
+ }
57
+ }
58
+ const functions = rest.slice(index);
59
+ if (functions.length === 0) return { ok: false, tag, error: "a tag needs at least one function letter after the measured variable" };
60
+ const words = [];
61
+ let seenOutput = false;
62
+ for (let i = 0; i < functions.length; i++) {
63
+ const letter = functions[i];
64
+ if (FUNCTION_MODIFIER[letter]) {
65
+ const previous = functions[i - 1];
66
+ if (!previous || !(previous === "A" || previous === "S" || FUNCTION_MODIFIER[previous])) {
67
+ return { ok: false, tag, error: `${letter} (high/low) may only follow an alarm or switch letter` };
68
+ }
69
+ words.push(FUNCTION_MODIFIER[letter]);
70
+ continue;
71
+ }
72
+ if (READOUT[letter]) {
73
+ if (seenOutput && (letter === "I" || letter === "R")) return { ok: false, tag, error: `readout letter ${letter} must come before output letters` };
74
+ words.push(READOUT[letter]);
75
+ continue;
76
+ }
77
+ if (OUTPUT[letter]) {
78
+ seenOutput = true;
79
+ words.push(OUTPUT[letter]);
80
+ continue;
81
+ }
82
+ return { ok: false, tag, error: `${letter} is not a function letter` };
83
+ }
84
+ const repeated = functions.filter((letter, i) => !FUNCTION_MODIFIER[letter] && functions.indexOf(letter) !== i);
85
+ if (repeated.length) return { ok: false, tag, error: `function letter ${repeated[0]} repeats` };
86
+ const meaning = NAMED[letters] ?? [MEASURED[first], modifier ? VARIABLE_MODIFIER[modifier] : null, ...words].filter(Boolean).join(" ");
87
+ return { ok: true, tag, letters, loop, first, modifier, functions, meaning };
88
+ }
89
+
90
+ // Equipment tags: a class prefix of one to three letters, a number, an optional train suffix.
91
+ const EQUIPMENT = /^([A-Z]{1,3})-?([0-9]{2,5})([A-Z]?)$/u;
92
+ export const EQUIPMENT_CLASS = Object.freeze({
93
+ B: "blower", C: "column", CV: "conveyor", D: "drum", E: "heat exchanger", F: "filter",
94
+ H: "heater", K: "compressor", M: "mixer, agitator", P: "pump", PK: "package unit",
95
+ R: "reactor", S: "separator", T: "tower", TK: "tank", V: "vessel", X: "miscellaneous",
96
+ GC: "gas cylinder", CH: "chiller", BR: "booster", MFC: "mass flow controller", A: "analyzer",
97
+ });
98
+
99
+ export function parseEquipmentTag(tag) {
100
+ const match = EQUIPMENT.exec(String(tag ?? "").trim());
101
+ if (!match) return { ok: false, tag, error: "an equipment tag is a class prefix and a number, for example R-101 or P-101A" };
102
+ return { ok: true, tag, prefix: match[1], number: match[2], train: match[3] || null, meaning: EQUIPMENT_CLASS[match[1]] ?? null };
103
+ }
104
+
105
+ // Line numbers: nominal size, fluid code, sequence number, piping class, and an optional
106
+ // insulation or tracing code, joined by hyphens: 2"-P-1001-A1A, 1/4"-CH4-101-HP1, DN50-CW-205-B1-IH.
107
+ // Projects order the parts differently; this is the common size-first form most P&IDs use.
108
+ const LINE_NUMBER = /^((?:\d+(?:\s\d+\/\d+)?|\d+\/\d+)"|DN\d{1,4}|\d{1,4}mm)-([A-Z][A-Z0-9]{0,5})-(\d{2,6}[A-Z]?)-([A-Z0-9]{1,8})(?:-([A-Z]{1,4}))?$/u;
109
+
110
+ export function parseLineNumber(text) {
111
+ const match = LINE_NUMBER.exec(String(text ?? "").trim());
112
+ if (!match) return { ok: false, text, error: "a line number is size-fluid-sequence-class[-insulation], for example 2\"-P-1001-A1A" };
113
+ const [, size, fluid, sequence, spec, insulation = null] = match;
114
+ return { ok: true, text, size, fluid, sequence, spec, insulation };
115
+ }