Setting the file. One moment. Industrial Ring · Dreambase Industrial Schematics · DreambaseAI/skills · Skills Docsscripts/industrial-ring.mjs
JavaScript·291 lines·11 KB
14
15const MONO = `ui-monospace, 'SF Mono', 'JetBrains Mono', Menlo, monospace`;
16
17const RING_DEFAULTS = {
18 width: 1600,
19 height: 900,
20 cx: null, // default: width/2
21 cy: null, // default: height*0.52
22 radius: 460,
23 tilt: 62, // degrees tilted away from viewer (0 = face-on)
24 rotate: -16, // in-plane rotation, degrees
25 bg: "#0B0B0C", // set to "transparent" to skip the ground rect
26 ink: "#F2F0ED",
27 inkDim: "#8F8C88",
28 ticks: { count: 144, length: 22, width: 1.3, offset: 0 },
29 innerRing: false, // or a number = radius factor (e.g. 0.62)
30 arc: { start: -50, sweep: 50, width: 3.5 }, // bright arc; null to skip
31 nodes: [],
32 // node: { angle, label, sub, active } — angle in ring degrees (0 = top, cw)
33 dotField: false, // sparse interior dots
34 grain: 0.05, // alpha slope; 0 disables
35 dof: true, // blur far side
36};
37
38const RULER_DEFAULTS = {
39 width: 640,
40 height: 44,
41 count: 56,
42 progress: 0.25, // 0..1 — ticks below this are "completed"
43 track: "#1A1817", // rounded track fill; "transparent" to skip
44 tick: "#4A4846",
45 done: "#B0413A",
46 active: "#E05A4E",
47 pad: 14,
48 radius: 8,
49};
50
51// ---------------------------------------------------------------- helpers
52
53function fail(msg) {
54 process.stderr.write(`industrial-ring: ${msg}\n`);
55 process.exit(1);
56}
57
58function deepMerge(base, over) {
59 const out = { ...base };
60 for (const [k, v] of Object.entries(over || {})) {
61 out[k] =
62 v && typeof v === "object" && !Array.isArray(v) && base[k] && typeof base[k] === "object" && !Array.isArray(base[k])
63 ? deepMerge(base[k], v)
64 : v;
65 }
66 return out;
67}
68
69function project(angleDeg, r, tilt, rot, cx, cy) {
70 const a = ((angleDeg - 90) * Math.PI) / 180; // 0deg = 12 o'clock, clockwise
71 const x0 = r * Math.cos(a);
72 const y0 = r * Math.sin(a) * Math.cos((tilt * Math.PI) / 180);
73 const rr = (rot * Math.PI) / 180;
74 return {
75 x: cx + x0 * Math.cos(rr) - y0 * Math.sin(rr),
76 y: cy + x0 * Math.sin(rr) + y0 * Math.cos(rr),
77 depth: (Math.sin(a) + 1) / 2, // 0 far (top of ring), 1 near (bottom)
78 };
79}
80
81const fmt = (n) => (Math.round(n * 100) / 100).toString();
82const esc = (s) =>
83 String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
84
85function samplePath(from, to, step, r, cfg) {
86 const pts = [];
87 for (let a = from; a <= to + 1e-9; a += step) {
88 const p = project(a, r, cfg.tilt, cfg.rotate, cfg.cx, cfg.cy);
89 pts.push(`${fmt(p.x)} ${fmt(p.y)}`);
90 }
91 return `M ${pts.join(" L ")}`;
92}
93
94// ------------------------------------------------------------------- ring
95
96function renderRing(cfg) {
97 cfg = deepMerge(RING_DEFAULTS, cfg);
98 cfg.cx = cfg.cx ?? cfg.width / 2;
99 cfg.cy = cfg.cy ?? cfg.height * 0.52;
100 const { ink, inkDim } = cfg;
101 const far = [];
102 const near = [];
103 const top = [];
104 const put = (depth, s) => (depth < 0.45 ? far : near).push(s);
105 const opa = (depth) => 0.15 + 0.85 * Math.pow(depth, 1.2);
106
107 // ring line — sampled polyline split into far/near halves
108 for (const [a0, a1, bucket] of [
109 [270, 450, far], // far half (through 0deg/top)
110 [90, 270, near], // near half (through 180deg/bottom)
111 ]) {
112 const d = samplePath(a0, a1, 2, cfg.radius, cfg);
113 const o = bucket === far ? 0.22 : 0.65;
114 bucket.push(`<path d="${d}" fill="none" stroke="${ink}" stroke-width="1.2" opacity="${o}"/>`);
115 }
116
117 // inner ring
118 if (cfg.innerRing) {
119 const f = typeof cfg.innerRing === "number" ? cfg.innerRing : 0.62;
120 far.push(`<path d="${samplePath(270, 450, 3, cfg.radius * f, cfg)}" fill="none" stroke="${ink}" stroke-width="0.8" opacity="0.1"/>`);
121 near.push(`<path d="${samplePath(90, 270, 3, cfg.radius * f, cfg)}" fill="none" stroke="${ink}" stroke-width="0.8" opacity="0.28"/>`);
122 }
123
124 // tick corona
125 const t = cfg.ticks;
126 if (t && t.count > 0) {
127 for (let i = 0; i < t.count; i++) {
128 const a = (i * 360) / t.count + (t.offset || 0);
129 const p1 = project(a, cfg.radius - 4, cfg.tilt, cfg.rotate, cfg.cx, cfg.cy);
130 const p2 = project(a, cfg.radius - 4 - t.length, cfg.tilt, cfg.rotate, cfg.cx, cfg.cy);
131 put(
132 p1.depth,
133 `<line x1="${fmt(p1.x)}" y1="${fmt(p1.y)}" x2="${fmt(p2.x)}" y2="${fmt(p2.y)}" stroke="${ink}" stroke-width="${t.width}" opacity="${fmt(0.45 * opa(p1.depth))}"/>`
134 );
135 }
136 }
137
138 // interior dot field
139 if (cfg.dotField) {
140 for (let ri = 0.2; ri <= 0.85; ri += 0.13) {
141 for (let a = 0; a < 360; a += 15) {
142 const p = project(a + ri * 40, cfg.radius * ri, cfg.tilt, cfg.rotate, cfg.cx, cfg.cy);
143 put(p.depth, `<circle cx="${fmt(p.x)}" cy="${fmt(p.y)}" r="1" fill="${ink}" opacity="${fmt(0.12 * opa(p.depth))}"/>`);
144 }
145 }
146 }
147
148 // bright progress arc (halo + core), drawn above everything but grain
149 if (cfg.arc) {
150 const { start, sweep, width } = cfg.arc;
151 const d = samplePath(start, start + sweep, 1.5, cfg.radius, cfg);
152 top.push(`<!-- progress arc -->`);
153 top.push(`<path d="${d}" fill="none" stroke="${ink}" stroke-width="${width * 2.6}" stroke-linecap="round" opacity="0.25" filter="url(#glow)"/>`);
154 top.push(`<path d="${d}" fill="none" stroke="${ink}" stroke-width="${width}" stroke-linecap="round" filter="url(#glow)"/>`);
155 }
156
157 // nodes + labels
158 for (const n of cfg.nodes || []) {
159 const p = project(n.angle, cfg.radius, cfg.tilt, cfg.rotate, cfg.cx, cfg.cy);
160 const pOut = project(n.angle, cfg.radius + 34, cfg.tilt, cfg.rotate, cfg.cx, cfg.cy);
161 const ux = pOut.x - p.x, uy = pOut.y - p.y;
162 const ul = Math.hypot(ux, uy) || 1;
163 const active = !!n.active;
164 const g = [];
165 g.push(`<!-- node: ${esc(n.label || "")} -->`);
166 g.push(`<circle cx="${fmt(p.x)}" cy="${fmt(p.y)}" r="7" fill="none" stroke="${ink}" stroke-width="1.2" opacity="${active ? 0.8 : 0.35}"/>`);
167 if (active)
168 g.push(`<circle cx="${fmt(p.x + (ux / ul) * 6)}" cy="${fmt(p.y + (uy / ul) * 6)}" r="3.5" fill="${ink}" filter="url(#glow)"/>`);
169 if (n.label) {
170 const right = pOut.x >= p.x;
171 const lx = p.x + (ux / ul) * 26, ly = p.y + (uy / ul) * 26;
172 g.push(
173 `<text x="${fmt(lx)}" y="${fmt(ly)}" fill="${active ? ink : inkDim}" font-family="${MONO}" font-size="12.5" letter-spacing="3.2" text-anchor="${right ? "start" : "end"}" opacity="${active ? 1 : 0.75}">${esc(String(n.label).toUpperCase())}</text>`
174 );
175 }
176 if (n.sub) {
177 // secondary label along the ring's inside, rotated to local tangent
178 const pIn = project(n.angle + 4, cfg.radius - 44, cfg.tilt, cfg.rotate, cfg.cx, cfg.cy);
179 const q1 = project(n.angle + 1, cfg.radius - 44, cfg.tilt, cfg.rotate, cfg.cx, cfg.cy);
180 const q2 = project(n.angle + 7, cfg.radius - 44, cfg.tilt, cfg.rotate, cfg.cx, cfg.cy);
181 let ang = (Math.atan2(q2.y - q1.y, q2.x - q1.x) * 180) / Math.PI;
182 if (ang > 90) ang -= 180;
183 if (ang < -90) ang += 180;
184 g.push(
185 `<text x="${fmt(pIn.x)}" y="${fmt(pIn.y)}" fill="${inkDim}" font-family="${MONO}" font-size="10" letter-spacing="4.5" opacity="0.55" transform="rotate(${fmt(ang)} ${fmt(pIn.x)} ${fmt(pIn.y)})">${esc(String(n.sub).toUpperCase())}</text>`
186 );
187 }
188 (p.depth < 0.45 ? far : top).push(g.join("\n "));
189 }
190
191 const defs = `
192 <defs>
193 <filter id="glow" x="-300%" y="-300%" width="700%" height="700%">
194 <feGaussianBlur stdDeviation="4" result="b1"/>
195 <feGaussianBlur in="SourceGraphic" stdDeviation="1.2" result="b2"/>
196 <feMerge><feMergeNode in="b1"/><feMergeNode in="b2"/><feMergeNode in="SourceGraphic"/></feMerge>
197 </filter>
198 <filter id="dof"><feGaussianBlur stdDeviation="2.2"/></filter>
199 <filter id="grain">
200 <feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="2" stitchTiles="stitch"/>
201 <feColorMatrix type="saturate" values="0"/>
202 <feComponentTransfer><feFuncA type="linear" slope="${cfg.grain}" intercept="0"/></feComponentTransfer>
203 </filter>
204 </defs>`;
205
206 return `<svg xmlns="http://www.w3.org/2000/svg" width="${cfg.width}" height="${cfg.height}" viewBox="0 0 ${cfg.width} ${cfg.height}" role="img">
207${defs}
208 ${cfg.bg !== "transparent" ? `<rect width="100%" height="100%" fill="${cfg.bg}"/>` : ""}
209 <!-- far side (blurred, dim) -->
210 <g${cfg.dof ? ` filter="url(#dof)"` : ""} opacity="0.55">
211 ${far.join("\n ")}
212 </g>
213 <!-- near side (sharp) -->
214 <g>
215 ${near.join("\n ")}
216 </g>
217 <!-- focal elements -->
218 <g>
219 ${top.join("\n ")}
220 </g>
221 ${cfg.grain ? `<rect width="100%" height="100%" filter="url(#grain)"/>` : ""}
222</svg>`;
223}
224
225// ------------------------------------------------------------------ ruler
226
227function renderRuler(cfg) {
228 cfg = deepMerge(RULER_DEFAULTS, cfg);
229 const innerW = cfg.width - cfg.pad * 2;
230 const gap = innerW / (cfg.count - 1);
231 const activeIdx = Math.round(cfg.progress * (cfg.count - 1));
232 const parts = [];
233 if (cfg.track !== "transparent")
234 parts.push(`<rect width="${cfg.width}" height="${cfg.height}" rx="${cfg.radius}" fill="${cfg.track}"/>`);
235 for (let i = 0; i < cfg.count; i++) {
236 const x = cfg.pad + i * gap;
237 const isActive = i === activeIdx;
238 const isDone = i < activeIdx;
239 const h = isActive ? cfg.height - 12 : cfg.height - 22;
240 const y = (cfg.height - h) / 2;
241 const color = isActive ? cfg.active : isDone ? cfg.done : cfg.tick;
242 parts.push(
243 `<rect x="${fmt(x - 0.9)}" y="${fmt(y)}" width="1.8" height="${fmt(h)}" rx="0.9" fill="${color}" opacity="${isActive ? 1 : isDone ? 0.6 : 0.7}"/>`
244 );
245 }
246 return `<svg xmlns="http://www.w3.org/2000/svg" width="${cfg.width}" height="${cfg.height}" viewBox="0 0 ${cfg.width} ${cfg.height}" role="img">
247 ${parts.join("\n ")}
248</svg>`;
249}
250
251// -------------------------------------------------------------------- cli
252
253const HELP = `industrial-ring.mjs — industrial schematic SVG scaffolds
254
255 node industrial-ring.mjs ring <config.json|'{json}'> [-o out.svg]
256 node industrial-ring.mjs ruler <config.json|'{json}'> [-o out.svg]
257
258ring config (defaults shown):
259${JSON.stringify(RING_DEFAULTS, null, 2)}
260
261 nodes: [{ "angle": 0, "label": "ISSUE ENTERS TRIAGE", "sub": "LOOP TRIGGER", "active": true }]
262 angle: ring degrees, 0 = 12 o'clock, clockwise.
263
264ruler config (defaults shown):
265${JSON.stringify(RULER_DEFAULTS, null, 2)}
266`;
267
268const argv = process.argv.slice(2);
269if (!argv.length || argv.includes("--help") || argv.includes("-h")) {
270 process.stdout.write(HELP);
271 process.exit(0);
272}
273const mode = argv[0];
274if (!["ring", "ruler"].includes(mode)) fail(`unknown mode "${mode}" (expected ring|ruler)`);
275let outPath = null;
276const oi = argv.indexOf("-o");
277if (oi !== -1) outPath = argv[oi + 1] || fail("-o requires a path");
278const cfgArg = argv[1] && argv[1] !== "-o" ? argv[1] : "{}";
279let cfg;
280try {
281 cfg = cfgArg.trim().startsWith("{") ? JSON.parse(cfgArg) : JSON.parse(readFileSync(cfgArg, "utf8"));
282} catch (e) {
283 fail(`could not parse config: ${e.message}`);
284}
285const svg = mode === "ring" ? renderRing(cfg) : renderRuler(cfg);
286if (outPath) {
287 writeFileSync(outPath, svg);
288 process.stderr.write(`wrote ${outPath}\n`);
289} else {
290 process.stdout.write(svg);
291}