From 6261ec7e056a24e1497a6a7c6987a1a585e282e8 Mon Sep 17 00:00:00 2001 From: Kristopher Date: Wed, 11 Nov 2026 23:27:23 +0900 Subject: [PATCH 01/40] Add isometric drawing functionality for homepage This file implements an isometric wireframe drawing system for homepage illustrations, including various shapes like boxes, cylinders, and lines. --- nuxt/lib/home-iso.ts | 149 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 nuxt/lib/home-iso.ts diff --git a/nuxt/lib/home-iso.ts b/nuxt/lib/home-iso.ts new file mode 100644 index 0000000000..6be484462b --- /dev/null +++ b/nuxt/lib/home-iso.ts @@ -0,0 +1,149 @@ +// Isometric wireframe drawing for the homepage illustrations. +// +// Every factory picture on the homepage (the hero plant, the four "where to start" +// icons, the one-plant-to-every-site diagram) is built here from a handful of 3D +// primitives (boxes, vertical cylinders, 3D polylines) and projected to 2D, rather +// than hand-drawn as SVG paths. Moving a machine is a coordinate change, and every +// picture shares one projection, one line weight and one palette. +// +// World axes: x runs to the lower right of the screen, y to the lower left, z up. +// The viewer looks from +x/+y, so a box shows its top, its x-max face and its y-max face. + +export type Tone = 'line' | 'accent' | 'muted' + +export interface Shape { + kind: 'poly' | 'path' | 'circle' + // poly: closed polygon points; path: an SVG path string; circle: centre + r + points?: [number, number][] + d?: string + cx?: number + cy?: number + r?: number + // face: top/left/right fill; none: stroke only; flow: an animated data line; + // dot: a status light + role: 'top' | 'left' | 'right' | 'none' | 'flow' | 'dot' + tone: Tone + depth: number + // explicit [minX, minY, maxX, maxY] for shapes whose path holds arcs + bounds?: [number, number, number, number] +} + +const COS30 = Math.cos(Math.PI / 6) +const SIN30 = 0.5 + +export function project (x: number, y: number, z: number): [number, number] { + return [(x - y) * COS30, (x + y) * SIN30 - z] +} + +function pt ([sx, sy]: [number, number]) { + return `${sx.toFixed(2)},${sy.toFixed(2)}` +} + +export class IsoScene { + shapes: Shape[] = [] + + // A solid box drawn as three outlined faces. `depth` sorts painter's-algorithm style: + // objects further back (smaller x + y) are drawn first. + box (x: number, y: number, z: number, w: number, d: number, h: number, tone: Tone = 'line', depthOverride?: number) { + const depth = depthOverride ?? x + y + (w + d) / 2 + z * 0.01 + const P = (a: number, b: number, c: number) => project(a, b, c) + this.shapes.push( + { kind: 'poly', role: 'right', tone, depth, points: [P(x + w, y, z), P(x + w, y + d, z), P(x + w, y + d, z + h), P(x + w, y, z + h)] }, + { kind: 'poly', role: 'left', tone, depth, points: [P(x, y + d, z), P(x + w, y + d, z), P(x + w, y + d, z + h), P(x, y + d, z + h)] }, + { kind: 'poly', role: 'top', tone, depth, points: [P(x, y, z + h), P(x + w, y, z + h), P(x + w, y + d, z + h), P(x, y + d, z + h)] }, + ) + return this + } + + // A flat rectangle on the y-max (front-left) face of a box: a screen or a panel. + panelY (y: number, x0: number, x1: number, z0: number, z1: number, tone: Tone = 'accent', depth = 0) { + const P = (a: number, c: number) => project(a, y, c) + this.shapes.push({ kind: 'poly', role: 'none', tone, depth, points: [P(x0, z0), P(x1, z0), P(x1, z1), P(x0, z1)] }) + return this + } + + // A flat rectangle on the x-max (front-right) face of a box. + panelX (x: number, y0: number, y1: number, z0: number, z1: number, tone: Tone = 'accent', depth = 0) { + const P = (b: number, c: number) => project(x, b, c) + this.shapes.push({ kind: 'poly', role: 'none', tone, depth, points: [P(y0, z0), P(y1, z0), P(y1, z1), P(y0, z1)] }) + return this + } + + // A vertical cylinder (a tank, a pump body, a robot base). A circle in the floor + // plane projects to an axis-aligned ellipse, rx = r·√2·cos30, ry = r·√2·sin30. + cylinder (cx: number, cy: number, z: number, r: number, h: number, tone: Tone = 'line', rings = 0) { + const depth = cx + cy + z * 0.01 + const rx = r * Math.SQRT2 * COS30 + const ry = r * Math.SQRT2 * SIN30 + const [bx, by] = project(cx, cy, z) + const [tx, ty] = project(cx, cy, z + h) + const side = `M${(bx - rx).toFixed(2)},${by.toFixed(2)} L${(tx - rx).toFixed(2)},${ty.toFixed(2)} L${(tx + rx).toFixed(2)},${ty.toFixed(2)} L${(bx + rx).toFixed(2)},${by.toFixed(2)} A${rx.toFixed(2)},${ry.toFixed(2)} 0 0 1 ${(bx - rx).toFixed(2)},${by.toFixed(2)} Z` + const top = `M${(tx - rx).toFixed(2)},${ty.toFixed(2)} A${rx.toFixed(2)},${ry.toFixed(2)} 0 1 0 ${(tx + rx).toFixed(2)},${ty.toFixed(2)} A${rx.toFixed(2)},${ry.toFixed(2)} 0 1 0 ${(tx - rx).toFixed(2)},${ty.toFixed(2)} Z` + const bounds: [number, number, number, number] = [bx - rx, ty - ry, bx + rx, by + ry] + this.shapes.push({ kind: 'path', role: 'left', tone, depth, d: side, bounds }) + for (let i = 1; i <= rings; i++) { + const [rx0, ry0] = project(cx, cy, z + (h * i) / (rings + 1)) + this.shapes.push({ kind: 'path', role: 'none', tone: 'muted', depth: depth + 0.001, d: `M${(rx0 - rx).toFixed(2)},${ry0.toFixed(2)} A${rx.toFixed(2)},${ry.toFixed(2)} 0 0 0 ${(rx0 + rx).toFixed(2)},${ry0.toFixed(2)}` }) + } + this.shapes.push({ kind: 'path', role: 'top', tone, depth: depth + 0.002, d: top, bounds }) + return this + } + + // An open 3D polyline: a pipe, a robot arm, a cable. `flow` animates it as data. + line (points: [number, number, number][], tone: Tone = 'line', role: 'none' | 'flow' = 'none', depth?: number) { + const projected = points.map(([a, b, c]) => project(a, b, c)) + const d = 'M' + projected.map(pt).join(' L') + const avg = depth ?? points.reduce((s, [a, b]) => s + a + b, 0) / points.length + this.shapes.push({ kind: 'path', role, tone, depth: avg, d }) + return this + } + + // A status light at a 3D point. + dot (x: number, y: number, z: number, r = 0.9, tone: Tone = 'accent', depth?: number) { + const [cx, cy] = project(x, y, z) + this.shapes.push({ kind: 'circle', role: 'dot', tone, depth: depth ?? x + y + 1000, cx, cy, r }) + return this + } + + // The floor slab and its grid, drawn first. + floor (x: number, y: number, w: number, d: number, step = 0) { + const P = (a: number, b: number) => project(a, b, 0) + this.shapes.push({ kind: 'poly', role: 'top', tone: 'muted', depth: -1e6, points: [P(x, y), P(x + w, y), P(x + w, y + d), P(x, y + d)] }) + if (step > 0) { + const lines: string[] = [] + for (let gx = x + step; gx < x + w; gx += step) lines.push(`M${pt(P(gx, y))} L${pt(P(gx, y + d))}`) + for (let gy = y + step; gy < y + d; gy += step) lines.push(`M${pt(P(x, gy))} L${pt(P(x + w, gy))}`) + this.shapes.push({ kind: 'path', role: 'none', tone: 'muted', depth: -1e6 + 1, d: lines.join(' ') }) + } + return this + } + + // Painter's-algorithm order, plus a padded viewBox around everything drawn. + render (pad = 2) { + const shapes = [...this.shapes].sort((a, b) => a.depth - b.depth) + let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity + const grow = (x: number, y: number) => { + minX = Math.min(minX, x); minY = Math.min(minY, y) + maxX = Math.max(maxX, x); maxY = Math.max(maxY, y) + } + for (const s of shapes) { + if (s.points) s.points.forEach(([x, y]) => grow(x, y)) + if (s.kind === 'circle') { grow(s.cx! - s.r!, s.cy! - s.r!); grow(s.cx! + s.r!, s.cy! + s.r!) } + if (s.bounds) { grow(s.bounds[0], s.bounds[1]); grow(s.bounds[2], s.bounds[3]) } + else if (s.d) { + // Every coordinate pair in our own path strings is "x,y" or "x y" after a command. + const nums = s.d.match(/-?\d+(\.\d+)?/g)?.map(Number) ?? [] + if (!s.d.includes('A')) for (let i = 0; i + 1 < nums.length; i += 2) grow(nums[i], nums[i + 1]) + } + } + // Arcs only ever sit inside the box/cylinder outline already counted above. + const viewBox = `${(minX - pad).toFixed(1)} ${(minY - pad).toFixed(1)} ${(maxX - minX + pad * 2).toFixed(1)} ${(maxY - minY + pad * 2).toFixed(1)}` + return { + viewBox, + shapes: shapes.map(s => ({ + ...s, + points: s.points ? s.points.map(pt).join(' ') : undefined, + })), + } + } +} From 27aa9e2f98258b9f38178077afab881ea39e7dab Mon Sep 17 00:00:00 2001 From: Kristopher Date: Wed, 11 Nov 2026 23:27:49 +0900 Subject: [PATCH 02/40] Add homepage wireframe scenes with IsoScene --- nuxt/lib/home-iso-scenes.ts | 147 ++++++++++++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 nuxt/lib/home-iso-scenes.ts diff --git a/nuxt/lib/home-iso-scenes.ts b/nuxt/lib/home-iso-scenes.ts new file mode 100644 index 0000000000..963c1995b2 --- /dev/null +++ b/nuxt/lib/home-iso-scenes.ts @@ -0,0 +1,147 @@ +// The homepage's wireframe scenes, drawn with the IsoScene primitives in home-iso.ts. +// Coordinates are world units; render() fits the viewBox, so a scene's absolute +// scale doesn't matter, only its proportions. +import { IsoScene } from './home-iso' + +export type SceneName = 'hero' | 'sites' | 'operations' | 'maintenance' | 'automation' | 'digital' + +// Flow lines are drawn above every solid, so data reads as travelling over the plant. +const OVER = 1e5 + +// A plant floor: edge gateway, PLC cabinet, three machines, a conveyor carrying +// product to a robot cell, two tanks, and every source wired up to one hub above. +function hero () { + const s = new IsoScene() + s.floor(0, 0, 64, 44, 4) + + + // Three machining centres, each with a screen, a door and a status light + for (let i = 0; i < 3; i++) { + const x = 9 + i * 13 + s.box(x, 8, 0, 10, 8, 9) + s.panelY(16, x + 1.5, x + 4.5, 4, 7.5, 'accent', x + 8 + 9.1) + s.panelY(16, x + 5.5, x + 9, 1, 7.5, 'muted', x + 8 + 9.1) + s.dot(x + 8.5, 9.5, 9, 0.6, i === 1 ? 'muted' : 'accent', x + 20) + } + + // Conveyor: frame, legs, rollers and product + for (let k = 0; k < 6; k++) { + s.box(7 + k * 8, 24.4, 0, 0.8, 0.8, 2) + s.box(7 + k * 8, 28, 0, 0.8, 0.8, 2) + } + s.box(6, 24, 2, 44, 5, 1.2) + for (let k = 0; k < 22; k++) { + const x = 7 + k * 2 + s.line([[x, 24, 3.2], [x, 29, 3.2]], 'muted', 'none', 6 + 24 + 30) + } + // Product rides on the belt, so it's drawn after the belt and its rollers + for (let k = 0; k < 5; k++) s.box(9 + k * 7.5, 25, 3.2, 3, 3, 2.4, k === 2 ? 'accent' : 'line', 61 + k) + + // Robot cell at the end of the line + s.cylinder(55, 31, 0, 2.6, 3) + s.line([[55, 31, 3], [55, 31, 10], [50.5, 28, 12.5], [47, 26.5, 7.5]], 'accent', 'none', 88) + s.dot(55, 31, 10, 0.6, 'accent', 89) + s.dot(50.5, 28, 12.5, 0.6, 'accent', 89) + + // Tanks and the pipe that feeds the line + s.cylinder(56, 9, 0, 4, 15, 'line', 2) + s.cylinder(56, 20, 0, 3, 10, 'line', 1) + s.line([[53.5, 20, 3], [52, 20, 3], [52, 24, 3]], 'line', 'none', 74) + + // PLC cabinet with edge gateway antenna, front left, I/O modules on its face + s.box(2, 30, 0, 3, 9, 8) + s.line([[3.5, 38, 8], [3.5, 38, 11]], 'line', 'none', 42) + s.dot(3.5, 38, 11, 0.6, 'accent', 43) + for (let m = 0; m < 4; m++) s.panelX(5, 31 + m * 2, 32.4 + m * 2, 2, 6.5, m === 0 ? 'accent' : 'muted', 40) + + // Data: every source rises to a shared bus, and the bus feeds the hub + const bus = 21 + s.line([[3.5, 6, bus], [56, 6, bus]], 'muted', 'none', OVER) + const risers: [number, number, number][] = [[14, 12, 9], [27, 12, 9], [40, 12, 9], [56, 9, 15]] + for (const [x, y, z] of risers) s.line([[x, y, z], [x, y, bus], [x, 6, bus]], 'accent', 'flow', OVER + 1) + s.line([[3.5, 30, 8], [3.5, 30, bus], [3.5, 6, bus]], 'accent', 'flow', OVER + 1) + s.line([[30, 6, bus], [30, 6, 26]], 'accent', 'flow', OVER + 1) + s.box(27, 3, 26, 6, 6, 3, 'accent') + s.shapes[s.shapes.length - 1].depth = OVER + 2 + s.shapes[s.shapes.length - 2].depth = OVER + 2 + s.shapes[s.shapes.length - 3].depth = OVER + 2 + s.dot(30, 6, 29, 0.8, 'accent', OVER + 3) + return s +} + +// One plant tile for the sites diagram: slab, main hall, annex and a silo. +function plant (s: IsoScene, ox: number, oy: number, tone: 'line' | 'accent') { + s.box(ox, oy, 0, 12, 10, 0.6, 'muted') + s.box(ox + 1, oy + 1, 0.6, 6, 5, 4, tone) + s.box(ox + 7.5, oy + 1.5, 0.6, 3, 3, 2.5, tone) + s.cylinder(ox + 9.2, oy + 7.2, 0.6, 1.3, 4.5, tone) + s.dot(ox + 4, oy + 3.5, 4.6, 0.6, 'accent', ox + oy + 50) +} + +// One plant on the left, proven; four on the right, each receiving the same solution. +function sites () { + const s = new IsoScene() + const source: [number, number] = [2, 26] + const targets: [number, number][] = [[28, -2], [47, -2], [28, 17], [47, 17]] + plant(s, ...source, 'accent') + for (const t of targets) plant(s, ...t, 'line') + const from: [number, number, number] = [source[0] + 4, source[1] + 3.5, 5] + for (const [tx, ty] of targets) { + const to: [number, number, number] = [tx + 4, ty + 3.5, 5] + const mid: [number, number, number] = [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2, 13] + s.line([from, mid, to], 'accent', 'flow', OVER) + } + return s +} + +function operations () { + const s = new IsoScene() + for (let k = 0; k < 3; k++) { + s.box(0.5 + k * 6.5, 0.3, 0, 0.6, 0.6, 1.6) + s.box(0.5 + k * 6.5, 3.1, 0, 0.6, 0.6, 1.6) + } + s.box(0, 0, 1.6, 16, 4, 0.9) + for (let k = 0; k < 3; k++) s.box(1.5 + k * 5, 0.6, 2.5, 2.6, 2.6, 2.2, k === 1 ? 'accent' : 'line') + return s +} + +function maintenance () { + const s = new IsoScene() + s.box(0, 0, 0, 12, 6, 1.2, 'muted') + s.cylinder(3.5, 3, 1.2, 2.3, 5, 'line', 1) + s.cylinder(8.5, 3, 1.2, 1.6, 3.2, 'line') + s.line([[8.5, 3, 4.4], [8.5, 3, 7], [13, 3, 7]], 'line', 'none', 30) + s.dot(3.5, 3, 6.2, 0.7, 'accent', 40) + return s +} + +function automation () { + const s = new IsoScene() + s.box(0, 0, 0, 1, 12.5, 8, 'muted') + for (let i = 0; i < 5; i++) { + const y = 0.5 + i * 2.4 + s.box(1, y, 0.6, 3.4, 2, 6.6, i === 0 ? 'accent' : 'line') + s.dot(4.4, y + 1, 5.6, 0.32, i === 0 ? 'accent' : 'muted', 100 + i) + } + return s +} + +function digital () { + const s = new IsoScene() + s.box(5.5, 5.5, 8, 4, 4, 2, 'accent') + const plants: [number, number][] = [[0, 12], [12, 0], [12, 12]] + for (const [x, y] of plants) { + s.box(x, y, 0, 4, 4, 3.5) + s.line([[x + 2, y + 2, 3.5], [7.5, 7.5, 8]], 'accent', 'flow', OVER) + } + return s +} + +const BUILDERS: Record IsoScene> = { hero, sites, operations, maintenance, automation, digital } + +const cache = new Map>() + +export function renderScene (name: SceneName) { + if (!cache.has(name)) cache.set(name, BUILDERS[name]().render(name === 'hero' ? 3 : 1.5)) + return cache.get(name)! +} From 5a925796044e534ad08bad14eee63cbf2fea4221 Mon Sep 17 00:00:00 2001 From: Kristopher Date: Wed, 11 Nov 2026 23:28:26 +0900 Subject: [PATCH 03/40] Add IsoArt component for isometric wireframe scenes --- nuxt/components/home/IsoArt.vue | 93 +++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 nuxt/components/home/IsoArt.vue diff --git a/nuxt/components/home/IsoArt.vue b/nuxt/components/home/IsoArt.vue new file mode 100644 index 0000000000..86264f5a9d --- /dev/null +++ b/nuxt/components/home/IsoArt.vue @@ -0,0 +1,93 @@ + + + + + From fd37478073078c9d50b873c49339947739d7fdb8 Mon Sep 17 00:00:00 2001 From: Kristopher Date: Wed, 11 Nov 2026 23:29:09 +0900 Subject: [PATCH 04/40] Homepage refresh --- nuxt/pages/index.vue | 576 ++++++++++++++++++------------------------- 1 file changed, 240 insertions(+), 336 deletions(-) diff --git a/nuxt/pages/index.vue b/nuxt/pages/index.vue index 13a2ca1e42..2c620862cd 100644 --- a/nuxt/pages/index.vue +++ b/nuxt/pages/index.vue @@ -1,388 +1,292 @@ + + From 38a8e20df1e112df7bf65ed5be7f8a52789548ec Mon Sep 17 00:00:00 2001 From: Kristopher Date: Wed, 11 Nov 2026 23:47:12 +0900 Subject: [PATCH 05/40] Refactor plant and site functions for clarity --- nuxt/lib/home-iso-scenes.ts | 40 ++++++++++++++++++++++--------------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/nuxt/lib/home-iso-scenes.ts b/nuxt/lib/home-iso-scenes.ts index 963c1995b2..17390fda0d 100644 --- a/nuxt/lib/home-iso-scenes.ts +++ b/nuxt/lib/home-iso-scenes.ts @@ -71,25 +71,30 @@ function hero () { // One plant tile for the sites diagram: slab, main hall, annex and a silo. function plant (s: IsoScene, ox: number, oy: number, tone: 'line' | 'accent') { - s.box(ox, oy, 0, 12, 10, 0.6, 'muted') + // The slab sits under the whole tile, so it's painted before anything on it + s.box(ox, oy, 0, 12, 10, 0.6, 'muted', ox + oy - 1) s.box(ox + 1, oy + 1, 0.6, 6, 5, 4, tone) s.box(ox + 7.5, oy + 1.5, 0.6, 3, 3, 2.5, tone) s.cylinder(ox + 9.2, oy + 7.2, 0.6, 1.3, 4.5, tone) - s.dot(ox + 4, oy + 3.5, 4.6, 0.6, 'accent', ox + oy + 50) } // One plant on the left, proven; four on the right, each receiving the same solution. function sites () { const s = new IsoScene() const source: [number, number] = [2, 26] - const targets: [number, number][] = [[28, -2], [47, -2], [28, 17], [47, 17]] + const targets: [number, number][] = [[38, 30], [46, 16], [47, -5], [37, -31]] plant(s, ...source, 'accent') for (const t of targets) plant(s, ...t, 'line') - const from: [number, number, number] = [source[0] + 4, source[1] + 3.5, 5] + // Every site has a short mast on its roof. The links run mast-top to mast-top, + // high enough that they pass above the buildings rather than through them. + const mast = 9 + const [fx, fy] = [source[0] + 4, source[1] + 3.5] + for (const [tx, ty] of [source, ...targets]) { + s.line([[tx + 4, ty + 3.5, 4.6], [tx + 4, ty + 3.5, mast]], 'line', 'none', tx + ty + 60) + s.dot(tx + 4, ty + 3.5, mast, 0.6, 'accent', OVER + 1) + } for (const [tx, ty] of targets) { - const to: [number, number, number] = [tx + 4, ty + 3.5, 5] - const mid: [number, number, number] = [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2, 13] - s.line([from, mid, to], 'accent', 'flow', OVER) + s.line([[fx, fy, mast], [tx + 4, ty + 3.5, mast]], 'accent', 'flow', OVER) } return s } @@ -97,27 +102,29 @@ function sites () { function operations () { const s = new IsoScene() for (let k = 0; k < 3; k++) { - s.box(0.5 + k * 6.5, 0.3, 0, 0.6, 0.6, 1.6) - s.box(0.5 + k * 6.5, 3.1, 0, 0.6, 0.6, 1.6) + s.box(0.5 + k * 6.5, 0.3, 0, 0.6, 0.6, 1.6, 'line', k) + s.box(0.5 + k * 6.5, 3.1, 0, 0.6, 0.6, 1.6, 'line', 10 + k) } - s.box(0, 0, 1.6, 16, 4, 0.9) - for (let k = 0; k < 3; k++) s.box(1.5 + k * 5, 0.6, 2.5, 2.6, 2.6, 2.2, k === 1 ? 'accent' : 'line') + // Belt over the legs, product over the belt + s.box(0, 0, 1.6, 16, 4, 0.9, 'line', 20) + for (let k = 0; k < 3; k++) s.box(1.5 + k * 5, 0.6, 2.5, 2.6, 2.6, 2.2, k === 1 ? 'accent' : 'line', 30 + k) return s } function maintenance () { const s = new IsoScene() - s.box(0, 0, 0, 12, 6, 1.2, 'muted') + s.box(0, 0, 0, 12, 6, 1.2, 'muted', -1) s.cylinder(3.5, 3, 1.2, 2.3, 5, 'line', 1) s.cylinder(8.5, 3, 1.2, 1.6, 3.2, 'line') - s.line([[8.5, 3, 4.4], [8.5, 3, 7], [13, 3, 7]], 'line', 'none', 30) + // Discharge pipe: up off the pump, across, and down into the base + s.line([[8.5, 3, 4.4], [8.5, 3, 6.5], [11, 3, 6.5], [11, 3, 1.2]], 'line', 'none', 30) s.dot(3.5, 3, 6.2, 0.7, 'accent', 40) return s } function automation () { const s = new IsoScene() - s.box(0, 0, 0, 1, 12.5, 8, 'muted') + s.box(0, 0, 0, 1, 12.5, 8, 'muted', -1) for (let i = 0; i < 5; i++) { const y = 0.5 + i * 2.4 s.box(1, y, 0.6, 3.4, 2, 6.6, i === 0 ? 'accent' : 'line') @@ -128,12 +135,13 @@ function automation () { function digital () { const s = new IsoScene() - s.box(5.5, 5.5, 8, 4, 4, 2, 'accent') + // Plants, then the links off their roofs, then the hub on top of the links const plants: [number, number][] = [[0, 12], [12, 0], [12, 12]] for (const [x, y] of plants) { s.box(x, y, 0, 4, 4, 3.5) - s.line([[x + 2, y + 2, 3.5], [7.5, 7.5, 8]], 'accent', 'flow', OVER) + s.line([[x + 2, y + 2, 3.5], [7.5, 7.5, 8]], 'accent', 'flow', 50) } + s.box(5.5, 5.5, 8, 4, 4, 2, 'accent', 60) return s } From 297e9173cc09f26d4d36f9a557cd63b66c9664ab Mon Sep 17 00:00:00 2001 From: Kristopher Date: Thu, 12 Nov 2026 00:02:58 +0900 Subject: [PATCH 06/40] Revise content for improved clarity and focus --- nuxt/pages/index.vue | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/nuxt/pages/index.vue b/nuxt/pages/index.vue index 2c620862cd..708c619039 100644 --- a/nuxt/pages/index.vue +++ b/nuxt/pages/index.vue @@ -48,9 +48,9 @@ const PROBLEM = [ ] const SOLUTION = [ - 'The AI builds the change. Your engineer approves it.', - 'Proven at one site, deployed to every site from one place.', - 'Every change versioned, audited and reversible.', + 'A solution is proven once and deployed everywhere.', + 'Each deployment goes faster than the last.', + 'Your team owns the solution and pushes improvements to every site.', ] const STEPS = [ @@ -100,10 +100,10 @@ const TRUST = [ ] useSeoMeta({ - title: 'The Industrial AI that does the work', - description: 'FlowFuse builds the change, ships it to every site, and can\'t touch a machine without your approval.', - ogDescription: 'FlowFuse builds the change, ships it to every site, and can\'t touch a machine without your approval.', - keywords: 'Industrial AI, Node-RED, Industrial Applications, IIoT, Multi-site Manufacturing, Edge, Industrial Automation, Industry 4.0', + title: 'Deploy proven solutions across every site', + description: 'FlowFuse builds the first solution with your team on the equipment you already run, proves it at one site, and deploys it to the rest, faster with each one.', + ogDescription: 'FlowFuse builds the first solution with your team on the equipment you already run, proves it at one site, and deploys it to the rest, faster with each one.', + keywords: 'Industrial Solutions, Node-RED, Industrial Applications, IIoT, Multi-site Manufacturing, Edge, Industrial Automation, Industry 4.0', ogUrl: 'https://flowfuse.com/', twitterSite: '@FlowFuseinc', }) @@ -114,10 +114,10 @@ useSeoMeta({
-

Industrial AI for multi-site operations

-

The Industrial AI that does the work.

+

Industrial solutions for multi-site operations

+

Deploy proven solutions across every site, faster with each one.

- Builds the change. Ships it to every site. Can't touch a machine without your approval. + Mission-critical problems need more than software. They need a solution that works on the equipment you already run and delivers a result you can measure. We build the first one with your team, prove it at one site, and deploy it to the rest.

@@ -129,8 +129,8 @@ useSeoMeta({
-

Change approved

-

Rolling out to every line

+

Proven at site 1

+

Deploying to every site

From 787bdede29c8157ca009d03ce3a5b13cf81a9627 Mon Sep 17 00:00:00 2001 From: Kristopher Date: Thu, 12 Nov 2026 00:30:47 +0900 Subject: [PATCH 07/40] Refactor social proof section in index.vue Updated the social proof section to include testimonials and customer logos under the hero. Adjusted the layout and spacing for better presentation. --- nuxt/pages/index.vue | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/nuxt/pages/index.vue b/nuxt/pages/index.vue index 708c619039..b5054cdfd7 100644 --- a/nuxt/pages/index.vue +++ b/nuxt/pages/index.vue @@ -4,7 +4,8 @@ // 2. Choose where to start: four team entry points, one per /teams/ page // 3. Problem to solution: one plant proven, every site running it // 4. Metrics of success: customer results, each linked to its story -// 5. Social proof: logos, testimonials, security credentials +// (logo strip with the user count sits straight under the hero) +// 5. Social proof: testimonials, security credentials // 6. Call to action // // The illustrations are isometric wireframes generated in nuxt/lib/home-iso-scenes.ts @@ -137,6 +138,13 @@ useSeoMeta({
+ +
+
+ +
+
+
@@ -228,12 +236,9 @@ useSeoMeta({
-
+
-
- -
-
+

Real results from industrial teams

From b45435d5d1bf6c8a66d7e00a3fb81921ced82733 Mon Sep 17 00:00:00 2001 From: Kristopher Date: Thu, 12 Nov 2026 02:24:04 +0900 Subject: [PATCH 08/40] Update index.vue --- nuxt/pages/index.vue | 92 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 82 insertions(+), 10 deletions(-) diff --git a/nuxt/pages/index.vue b/nuxt/pages/index.vue index b5054cdfd7..32b527e1dc 100644 --- a/nuxt/pages/index.vue +++ b/nuxt/pages/index.vue @@ -3,6 +3,7 @@ // 1. Value statement (hero, with the wireframe plant) // 2. Choose where to start: four team entry points, one per /teams/ page // 3. Problem to solution: one plant proven, every site running it +// 3b. The Foundation Partnership: why each site is faster than the last // 4. Metrics of success: customer results, each linked to its story // (logo strip with the user count sits straight under the hero) // 5. Social proof: testimonials, security credentials @@ -10,6 +11,11 @@ // // The illustrations are isometric wireframes generated in nuxt/lib/home-iso-scenes.ts // and rendered by , in place of stock photography. +// +// The page sells one idea: each site is faster than the last, and the Foundation +// Partnership is what makes it so. Every secondary link leads to /build-with-us/ +// (the partnership page) through CtaLink, so clicks still count as the signUp CTA. +import { foundationSiteCenters } from '../lib/home-iso-scenes' const START_POINTS = [ { @@ -50,16 +56,32 @@ const PROBLEM = [ const SOLUTION = [ 'A solution is proven once and deployed everywhere.', - 'Each deployment goes faster than the last.', + 'Each site starts on the foundation the last one built, so it goes faster.', 'Your team owns the solution and pushes improvements to every site.', ] +// What the first solution leaves behind, reused at every site after it. +// Same four pieces as the FOUNDATION list on /build-with-us/; keep the two in step. +const FOUNDATION = [ + { icon: 'i-lucide-plug', title: 'Integrations', text: 'Connections to your machines, historians and business systems. Built at the first site, reused at the next.' }, + { icon: 'i-lucide-database', title: 'Data models', text: 'One structure for your operational data, so every plant speaks the same language.' }, + { icon: 'i-lucide-rocket', title: 'Deployment patterns', text: 'A tested way to roll the solution out to new lines and plants without starting over.' }, + { icon: 'i-lucide-shield-check', title: 'Governance', text: 'Roles, access and change control, set up once and applied everywhere.' }, +] + const STEPS = [ - { name: 'Define', text: 'We agree the problem and the number it moves.' }, - { name: 'Design', text: 'Our engineers build it with your team, on your equipment.' }, - { name: 'Deliver', text: 'It runs at one site, then every site.' }, + { name: 'Define', text: 'We agree the problem and the number it has to move.' }, + { name: 'Design', text: 'Our engineers build the first solution with your team, on your equipment, and lay the foundation as they go.' }, + { name: 'Deliver', text: 'Prove it at one site, then roll it out on the same foundation, with your team, a partner or ours.' }, ] +// One label under each site in the foundation picture, placed where that site is drawn. +const FOUNDATION_LABELS = foundationSiteCenters().map((at, i) => ({ + at, + name: `Site ${i + 1}`, + note: i === 0 ? 'Foundation built' : 'Foundation reused', +})) + // Every figure here is a customer result published in nuxt/content/customer-stories/. const METRICS = [ { @@ -122,7 +144,10 @@ useSeoMeta({

- + + Foundation Partnership + +
@@ -202,12 +227,59 @@ useSeoMeta({
-
+
+
+ + +
+
+

The Foundation Partnership

+

The first site builds the foundation. Every site after starts on it.

+

+ FlowFuse engineers build your first solution alongside your team, on the equipment you already run. What they build is more than one project. It's the foundation every later site reuses, so each rollout has less left to build. +

+ +
+ +
+
+

{{ l.name }}

+

{{ l.note }}

+
+
+
+ Built new at this site + Reused from the foundation +
+
+ +
+
+ +

{{ f.title }}

+

{{ f.text }}

+
+
+ +

0{{ i + 1 }}{{ step.name }}

{{ step.text }}

+ +
+ + + How the Foundation Partnership works + + +
@@ -259,13 +331,13 @@ useSeoMeta({

Bring us the problem that repeats across your sites.

-

We'll build the first one with you, prove it at one site, and roll it out to the rest.

+

We'll build the first one with you, prove it at one site, and roll it out to the rest on the same foundation.

- - How we work with you + + How the Foundation Partnership works - +
From 742c1ef5a80b14477d6bab32c12a7f348c6e6413 Mon Sep 17 00:00:00 2001 From: Kristopher Date: Thu, 12 Nov 2026 02:24:20 +0900 Subject: [PATCH 09/40] Add foundation scene and update scene names --- nuxt/lib/home-iso-scenes.ts | 55 +++++++++++++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 3 deletions(-) diff --git a/nuxt/lib/home-iso-scenes.ts b/nuxt/lib/home-iso-scenes.ts index 17390fda0d..c2150f092e 100644 --- a/nuxt/lib/home-iso-scenes.ts +++ b/nuxt/lib/home-iso-scenes.ts @@ -1,9 +1,9 @@ // The homepage's wireframe scenes, drawn with the IsoScene primitives in home-iso.ts. // Coordinates are world units; render() fits the viewBox, so a scene's absolute // scale doesn't matter, only its proportions. -import { IsoScene } from './home-iso' +import { IsoScene, project } from './home-iso' -export type SceneName = 'hero' | 'sites' | 'operations' | 'maintenance' | 'automation' | 'digital' +export type SceneName = 'hero' | 'sites' | 'foundation' | 'operations' | 'maintenance' | 'automation' | 'digital' // Flow lines are drawn above every solid, so data reads as travelling over the plant. const OVER = 1e5 @@ -99,6 +99,55 @@ function sites () { return s } +// The Foundation Partnership, as four sites in a row. Each site is a stack: four +// foundation layers (integrations, data model, deployment pattern, governance) under +// the solution itself. At site 1 every layer is built new (accent). Every later site +// reuses the whole stack (line) and only adds a thin cap of site-specific work, which +// is the picture of "faster with each one". Links carry the foundation along the row. +export const FOUNDATION_SITES = 4 +const STACK = { step: 15, size: 8, layer: 1.1, gap: 0.35, block: 3.4 } + +function foundationOrigin (i: number): [number, number] { + return [i * STACK.step, -i * STACK.step] +} + +function foundation () { + const s = new IsoScene() + const { size, layer, gap, block } = STACK + const top = 4 * (layer + gap) + for (let i = 0; i < FOUNDATION_SITES; i++) { + const [ox, oy] = foundationOrigin(i) + const built = i === 0 + const tone = built ? 'accent' : 'line' + // Stacks sit side by side on screen, so each gets its own depth band + const base = i * 10 + for (let k = 0; k < 4; k++) s.box(ox, oy, k * (layer + gap), size, size, layer, tone, base + k) + s.box(ox + 1.5, oy + 1.5, top, size - 3, size - 3, block, tone, base + 5) + // Site-specific work at every later site: a thin cap on the reused solution + if (!built) s.box(ox + 1.5, oy + 1.5, top + block, size - 3, size - 3, 0.7, 'accent', base + 6) + } + // The foundation travels from each site to the next, at the height of the layers + for (let i = 0; i + 1 < FOUNDATION_SITES; i++) { + const [ax, ay] = foundationOrigin(i) + const [bx, by] = foundationOrigin(i + 1) + // From this stack's right-most edge to the next stack's left-most edge: a level, + // horizontal run through the gap that touches neither stack's faces + s.line([[ax + size, ay, 2.9], [bx, by + size, 2.9]], 'accent', 'flow', i * 10 + 8) + } + return s +} + +// Where each foundation site sits across the rendered picture, as a fraction of its +// width, so the page can set a label under each one. +export function foundationSiteCenters () { + const [minX, , width] = renderScene('foundation').viewBox.split(' ').map(Number) + return Array.from({ length: FOUNDATION_SITES }, (_, i) => { + const [ox, oy] = foundationOrigin(i) + const [sx] = project(ox + STACK.size / 2, oy + STACK.size / 2, 0) + return (sx - minX) / width + }) +} + function operations () { const s = new IsoScene() for (let k = 0; k < 3; k++) { @@ -145,7 +194,7 @@ function digital () { return s } -const BUILDERS: Record IsoScene> = { hero, sites, operations, maintenance, automation, digital } +const BUILDERS: Record IsoScene> = { hero, sites, foundation, operations, maintenance, automation, digital } const cache = new Map>() From 0335b7ea1c7fe6a64ec9047707f538476227149b Mon Sep 17 00:00:00 2001 From: Kristopher Date: Thu, 12 Nov 2026 02:24:42 +0900 Subject: [PATCH 10/40] Update title and references to Foundation Partnership --- nuxt/pages/build-with-us/index.vue | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/nuxt/pages/build-with-us/index.vue b/nuxt/pages/build-with-us/index.vue index 796b9e4c6e..f0c14b4557 100644 --- a/nuxt/pages/build-with-us/index.vue +++ b/nuxt/pages/build-with-us/index.vue @@ -1,7 +1,7 @@ diff --git a/nuxt/data/site.json b/nuxt/data/site.json index 243ec1dee6..1c51176dc8 100644 --- a/nuxt/data/site.json +++ b/nuxt/data/site.json @@ -8,7 +8,7 @@ "messaging": { "tagLine": "The Edge-Native Platform for Industrial Applications", "heroTagLine": "The Edge-Native Platform for Industrial IT, OT, and IIOT Applications", - "subtitle": "Build industrial applications in minutes with AI, then deploy and govern production from cloud to edge.", + "subtitle": "FlowFuse builds your first industrial solution with your team, proves it at one plant, then helps you roll it out to every plant, faster each time.", "title": "Build workflows and integrations that optimize your industrial operations", "keywords": "Node-RED, Application Development, IoT, IIoT, Low-Code, open source, Integration, Workflow, Automation, Data Processing, Data Integration, Data Transformation, Data Visualization, Industrial Automation, Industrial IoT, Industry 4.0" }, @@ -25,8 +25,7 @@ "https://twitter.com/FlowFuseInc", "https://www.crunchbase.com/organization/flowforge", "https://www.g2.com/sellers/flowfuse", - "https://www.facebook.com/FlowFuse", - "https://www.reddit.com/r/FlowFuse/" + "https://www.facebook.com/FlowFuse" ] } } diff --git a/nuxt/nuxt.config.ts b/nuxt/nuxt.config.ts index 47cdc12ce4..4ec78c6a5c 100644 --- a/nuxt/nuxt.config.ts +++ b/nuxt/nuxt.config.ts @@ -212,7 +212,8 @@ export default defineNuxtConfig({ llms: { domain: site.baseURL, title: 'FlowFuse', - description: `${site.messaging.tagLine} - ${site.messaging.subtitle}`, + // The subtitle alone: the tagline still carries the old platform category. + description: site.messaging.subtitle, full: { title: 'FlowFuse - Full Documentation', description: 'Complete FlowFuse documentation, blog, changelog, and resources in a single markdown document.', diff --git a/nuxt/pages/index.vue b/nuxt/pages/index.vue index 90396eaa9f..82d8a4b920 100644 --- a/nuxt/pages/index.vue +++ b/nuxt/pages/index.vue @@ -122,8 +122,11 @@ const TRUST = [ { icon: 'i-lucide-server', label: 'Self-hosted or air-gapped' }, ] +// The homepage ranks for the brand, so its title leads with it. The site-wide template +// would append "• FlowFuse" again, so it's switched off for this page only. +useHead({ titleTemplate: '%s' }) useSeoMeta({ - title: 'Deploy proven solutions across every site', + title: 'FlowFuse | Deploy Proven Industrial Solutions at Every Site', description: 'FlowFuse builds the first solution with your team on the equipment you already run, proves it at one site, and deploys it to the rest, faster with each one.', ogDescription: 'FlowFuse builds the first solution with your team on the equipment you already run, proves it at one site, and deploys it to the rest, faster with each one.', keywords: 'Industrial Solutions, Node-RED, Industrial Applications, IIoT, Multi-site Manufacturing, Edge, Industrial Automation, Industry 4.0', From cc277289070f68b94a197d2b98eb996577d139c6 Mon Sep 17 00:00:00 2001 From: sumitshinde-84 Date: Thu, 12 Nov 2026 16:28:04 +0530 Subject: [PATCH 15/40] Homepage: replace role cards with an outcomes section The second section now picks up the hero's promise of a result you can measure: Quality, Delivery, Cost and Uptime, each with its own isometric scene and one line that pairs seeing the problem with acting on it. A closing line keeps it open to any KPI. Replaces the four /teams/ role cards and their Start here links. --- nuxt/components/home/IsoArt.vue | 1 + nuxt/lib/home-iso-scenes.ts | 74 +++++++++++++++++++++++++++++- nuxt/pages/index.vue | 81 +++++++++++++-------------------- 3 files changed, 105 insertions(+), 51 deletions(-) diff --git a/nuxt/components/home/IsoArt.vue b/nuxt/components/home/IsoArt.vue index 86264f5a9d..2fc3639578 100644 --- a/nuxt/components/home/IsoArt.vue +++ b/nuxt/components/home/IsoArt.vue @@ -82,6 +82,7 @@ path.r-none:not([d$='Z']) { fill: none; } } .r-dot { fill: var(--accent); stroke: none; } .r-dot.t-muted { fill: var(--muted); } +.r-dot.t-line { fill: var(--top); stroke: var(--stroke); } .r-dot.t-accent { animation: iso-pulse 2.4s ease-in-out infinite; } @keyframes iso-flow { to { stroke-dashoffset: -7.6; } } diff --git a/nuxt/lib/home-iso-scenes.ts b/nuxt/lib/home-iso-scenes.ts index 73d0c4b5b8..92a7e408ca 100644 --- a/nuxt/lib/home-iso-scenes.ts +++ b/nuxt/lib/home-iso-scenes.ts @@ -3,7 +3,7 @@ // scale doesn't matter, only its proportions. import { IsoScene, project } from './home-iso' -export type SceneName = 'hero' | 'sites' | 'foundation' | 'operations' | 'maintenance' | 'automation' | 'digital' +export type SceneName = 'hero' | 'sites' | 'foundation' | 'operations' | 'maintenance' | 'automation' | 'digital' | 'quality' | 'delivery' | 'cost' | 'uptime' // Flow lines are drawn above every solid, so data reads as travelling over the plant. const OVER = 1e5 @@ -191,7 +191,77 @@ function digital () { return s } -const BUILDERS: Record IsoScene> = { hero, sites, foundation, operations, maintenance, automation, digital } +// Scorecard scenes: one picture per card on the "Results you can measure" section. +// Accent always marks the signal FlowFuse raises: the caught defect, the current +// hour, the stop being costed, the warning. + +// Quality: parts travel under an inspection camera; the defective one is flagged +// mid-line, before it reaches the end. +function quality () { + const s = new IsoScene() + for (let k = 0; k < 3; k++) { + s.box(0.5 + k * 8, 0.3, 0, 0.6, 0.6, 1.6, 'line', k) + s.box(0.5 + k * 8, 3.1, 0, 0.6, 0.6, 1.6, 'line', 10 + k) + } + s.box(0, 0, 1.6, 19, 4, 0.9, 'line', 20) + // Camera gantry: far post behind the belt, near post and beam in front + s.box(9, -1, 0, 0.7, 0.7, 7.5, 'line', 15) + s.box(1.5, 0.7, 2.5, 2.6, 2.6, 2.2, 'line', 30) + s.box(8.2, 0.7, 2.5, 2.6, 2.6, 2.2, 'accent', 31) + s.box(14.5, 0.7, 2.5, 2.6, 2.6, 2.2, 'line', 32) + s.box(9, 4.3, 0, 0.7, 0.7, 7.5, 'line', 40) + s.box(9, -1, 7.5, 0.7, 6, 0.8, 'line', 41) + // Camera head on the beam, looking down at the flagged part + s.box(8.8, 1.6, 6.3, 1.1, 1.1, 1.2, 'line', 42) + s.line([[9.35, 2.15, 6.3], [9.5, 2, 4.9]], 'accent', 'flow', 43) + return s +} + +// Delivery: a loaded truck leaving the dock, the finished goods it carries. +function delivery () { + const s = new IsoScene() + // Pallet of finished goods waiting on the dock + s.box(-6, 0.5, 0, 4.5, 4.5, 0.5, 'muted', -5) + s.box(-5.5, 1, 0.5, 1.8, 1.8, 1.6, 'line', -4) + s.box(-3.5, 1, 0.5, 1.8, 1.8, 1.6, 'line', -3) + s.box(-5.5, 3, 0.5, 1.8, 1.8, 1.6, 'line', -2) + // Cargo body, then the cab with its windscreen + s.box(0, 0, 1.2, 10, 5, 5.5, 'line', 10) + s.box(10, 0.4, 1.2, 3.6, 4.2, 3.8, 'accent', 11) + s.panelX(13.6, 1, 4, 3.2, 4.6, 'accent', 12) + // Wheels on the near side + for (const x of [2, 7.5, 11.8]) s.dot(x, 5.2, 1.1, 1.05, 'line', 30) + return s +} + +// Cost: three coin stacks, each lower than the last. +function cost () { + const s = new IsoScene() + s.box(-2, -2, 0, 18, 7, 0.5, 'muted', -10) + const stacks: [number, number][] = [[0, 8], [6, 5.2], [12, 2.6]] + stacks.forEach(([x, h], i) => { + s.cylinder(x + 1.5, 1.5, 0.5, 2, h, i === 2 ? 'accent' : 'line', Math.round(h / 1.3) - 1) + }) + return s +} + +// Uptime: a machine with a stack light on top; the top segment is lit, the warning +// that comes before a failure. +function uptime () { + const s = new IsoScene() + s.box(-1, -1, 0, 11, 9, 0.5, 'muted', -10) + s.box(0, 0, 0.5, 8, 6.5, 6.5, 'line', 1) + s.panelY(6.5, 1, 4.2, 2.5, 5.5, 'muted', 2) + s.panelY(6.5, 5, 7, 1.2, 5.5, 'muted', 2) + // Stack light: pole, two unlit segments, the lit one on top + s.cylinder(6, 2, 7, 0.35, 1.2, 'line') + s.cylinder(6, 2, 8.2, 1, 1.4, 'line') + s.cylinder(6, 2, 9.6, 1, 1.4, 'line') + s.cylinder(6, 2, 11, 1, 1.4, 'accent') + return s +} + +const BUILDERS: Record IsoScene> = { hero, sites, foundation, operations, maintenance, automation, digital, quality, delivery, cost, uptime } const cache = new Map>() diff --git a/nuxt/pages/index.vue b/nuxt/pages/index.vue index 90396eaa9f..cf9bdc86dc 100644 --- a/nuxt/pages/index.vue +++ b/nuxt/pages/index.vue @@ -1,7 +1,7 @@ From 1ffc6d675098ed49d709348d3ecec9a4db6f99d1 Mon Sep 17 00:00:00 2001 From: sumitshinde-84 Date: Thu, 12 Nov 2026 18:41:53 +0530 Subject: [PATCH 23/40] Homepage: closing call to action card in the hero's colours --- nuxt/pages/index.vue | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/nuxt/pages/index.vue b/nuxt/pages/index.vue index 179356596b..fb70f14972 100644 --- a/nuxt/pages/index.vue +++ b/nuxt/pages/index.vue @@ -324,13 +324,15 @@ useSeoMeta({ From accf3a53bd979e2cb80a1daa637f92ede80d05cc Mon Sep 17 00:00:00 2001 From: Kristopher Date: Fri, 13 Nov 2026 00:12:20 +0900 Subject: [PATCH 25/40] Enhance IsoArt component with animation and active states Updated the IsoArt component to include new props for animation and active state, enhancing the SVG rendering logic and styles for better visual effects. --- nuxt/components/home/IsoArt.vue | 90 ++++++++++++++++++++++++++++++--- 1 file changed, 84 insertions(+), 6 deletions(-) diff --git a/nuxt/components/home/IsoArt.vue b/nuxt/components/home/IsoArt.vue index 2fc3639578..c2c9654003 100644 --- a/nuxt/components/home/IsoArt.vue +++ b/nuxt/components/home/IsoArt.vue @@ -2,29 +2,70 @@ // Renders one of the homepage's isometric wireframe scenes (nuxt/lib/home-iso-scenes.ts) // as inline SVG. Decorative, so it's hidden from assistive tech; the copy beside it // carries the meaning. Animation is CSS only and stops under prefers-reduced-motion. +// +// `animated` is for the hero: `active` names one group (see IsoScene.tag) that turns +// orange, and an orange trace redraws its outline each time `active` changes. The +// scene itself renders fully drawn, so the first step can start on page load. +// The trace draws with stroke-dashoffset on pathLength="1". Chrome ignores pathLength +// on non-scaling strokes, so an animated scene uses ordinary strokes instead, sized +// from the viewBox to land near the static scenes' 1.15px at desktop width. import { renderScene, type SceneName } from '../../lib/home-iso-scenes' const props = withDefaults(defineProps<{ scene: SceneName variant?: 'dark' | 'light' -}>(), { variant: 'light' }) + animated?: boolean + active?: string +}>(), { variant: 'light', animated: false, active: undefined }) const art = computed(() => renderScene(props.scene)) + +// Stroke width in viewBox units: 1.2px when the picture is drawn ~640px wide. +const strokeWidth = computed(() => { + const width = Number(art.value.viewBox.split(' ')[2]) + return (1.2 * width) / 640 +}) + +// Flow lines keep their own dash pattern, and dots have no outline, so neither is traced. +function traceable (role: string) { + return role !== 'flow' && role !== 'dot' +} @@ -82,13 +123,50 @@ path.r-none:not([d$='Z']) { fill: none; } } .r-dot { fill: var(--accent); stroke: none; } .r-dot.t-muted { fill: var(--muted); } -.r-dot.t-line { fill: var(--top); stroke: var(--stroke); } .r-dot.t-accent { animation: iso-pulse 2.4s ease-in-out infinite; } @keyframes iso-flow { to { stroke-dashoffset: -7.6; } } @keyframes iso-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } } +/* ---- Animated scenes (the hero) ---- */ + +.iso-animated polygon, .iso-animated path, .iso-animated circle { + vector-effect: none; + stroke-width: calc(var(--sw) * 1px); + transition: stroke 0.5s ease, fill 0.5s ease; +} +.iso-animated .r-flow { + stroke-width: calc(var(--sw) * 1.25px); + stroke-dasharray: calc(var(--sw) * 1.1px) calc(var(--sw) * 2.4px); + animation: iso-flow-scaled 1.6s linear infinite; +} +@keyframes iso-flow-scaled { to { stroke-dashoffset: calc(var(--sw) * -7px); } } + +/* The highlighted group: orange outline, warm fill, its data path running orange */ +.iso-animated .is-on { stroke: var(--accent); } +.iso-animated .is-on.r-top, .iso-animated .is-on.r-left, .iso-animated .is-on.r-right { fill: var(--accent-fill); } +.iso-animated .is-on.r-dot { fill: var(--accent); animation: iso-pulse 1.2s ease-in-out infinite; } +.iso-animated .r-flow.is-on { stroke-width: calc(var(--sw) * 1.6px); } + +/* The trace: a brighter, heavier orange line that draws itself around the group */ +.trace { + fill: none; + stroke: var(--accent); + stroke-width: calc(var(--sw) * 1.9px); + stroke-dasharray: 1; + stroke-dashoffset: 1; + filter: drop-shadow(0 0 calc(var(--sw) * 2.5px) rgba(255, 123, 107, 0.65)); + animation: iso-draw 0.9s cubic-bezier(0.5, 0, 0.2, 1) forwards; +} + +@keyframes iso-draw { to { stroke-dashoffset: 0; } } + @media (prefers-reduced-motion: reduce) { .r-flow, .r-dot.t-accent { animation: none; } + .iso-animated polygon, .iso-animated path, .iso-animated circle, .iso-animated .r-flow, .trace { + animation: none !important; + stroke-dashoffset: 0; + transition: none; + } } From 1a9f77d412c990ac0ed3c336389fd9133bbd59b1 Mon Sep 17 00:00:00 2001 From: Kristopher Date: Fri, 13 Nov 2026 00:12:35 +0900 Subject: [PATCH 26/40] Add group tagging and rendering for shapes --- nuxt/lib/home-iso.ts | 74 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 59 insertions(+), 15 deletions(-) diff --git a/nuxt/lib/home-iso.ts b/nuxt/lib/home-iso.ts index 6be484462b..9fb2c1dc6b 100644 --- a/nuxt/lib/home-iso.ts +++ b/nuxt/lib/home-iso.ts @@ -26,8 +26,13 @@ export interface Shape { depth: number // explicit [minX, minY, maxX, maxY] for shapes whose path holds arcs bounds?: [number, number, number, number] + // a named part of the scene (one machine, one line) that the page can highlight + group?: string } +// Where a named group sits in the rendered picture, as fractions of its width and height. +export interface GroupBox { x: number, y: number, w: number, h: number } + const COS30 = Math.cos(Math.PI / 6) const SIN30 = 0.5 @@ -42,6 +47,14 @@ function pt ([sx, sy]: [number, number]) { export class IsoScene { shapes: Shape[] = [] + // Tag every shape drawn inside `draw` as part of one named group. + tag (group: string, draw: () => void) { + const from = this.shapes.length + draw() + for (let i = from; i < this.shapes.length; i++) this.shapes[i].group = group + return this + } + // A solid box drawn as three outlined faces. `depth` sorts painter's-algorithm style: // objects further back (smaller x + y) are drawn first. box (x: number, y: number, z: number, w: number, d: number, h: number, tone: Tone = 'line', depthOverride?: number) { @@ -118,28 +131,33 @@ export class IsoScene { return this } - // Painter's-algorithm order, plus a padded viewBox around everything drawn. + // Painter's-algorithm order, a padded viewBox around everything drawn, and where + // each named group sits inside it. render (pad = 2) { const shapes = [...this.shapes].sort((a, b) => a.depth - b.depth) - let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity - const grow = (x: number, y: number) => { - minX = Math.min(minX, x); minY = Math.min(minY, y) - maxX = Math.max(maxX, x); maxY = Math.max(maxY, y) - } + const all = emptyBox() + const groups = new Map() for (const s of shapes) { - if (s.points) s.points.forEach(([x, y]) => grow(x, y)) - if (s.kind === 'circle') { grow(s.cx! - s.r!, s.cy! - s.r!); grow(s.cx! + s.r!, s.cy! + s.r!) } - if (s.bounds) { grow(s.bounds[0], s.bounds[1]); grow(s.bounds[2], s.bounds[3]) } - else if (s.d) { - // Every coordinate pair in our own path strings is "x,y" or "x y" after a command. - const nums = s.d.match(/-?\d+(\.\d+)?/g)?.map(Number) ?? [] - if (!s.d.includes('A')) for (let i = 0; i + 1 < nums.length; i += 2) grow(nums[i], nums[i + 1]) + const b = shapeBox(s) + if (!b) continue + growBox(all, b) + // A group's box covers its solid parts, not the data lines rising off it, + // so a callout pointing at the group lands on the machine itself + if (s.group && s.role !== 'flow') { + if (!groups.has(s.group)) groups.set(s.group, emptyBox()) + growBox(groups.get(s.group)!, b) } } - // Arcs only ever sit inside the box/cylinder outline already counted above. - const viewBox = `${(minX - pad).toFixed(1)} ${(minY - pad).toFixed(1)} ${(maxX - minX + pad * 2).toFixed(1)} ${(maxY - minY + pad * 2).toFixed(1)}` + const [minX, minY] = [all[0] - pad, all[1] - pad] + const [width, height] = [all[2] - all[0] + pad * 2, all[3] - all[1] + pad * 2] + const viewBox = `${minX.toFixed(1)} ${minY.toFixed(1)} ${width.toFixed(1)} ${height.toFixed(1)}` + const groupBoxes: Record = {} + for (const [name, b] of groups) { + groupBoxes[name] = { x: (b[0] - minX) / width, y: (b[1] - minY) / height, w: (b[2] - b[0]) / width, h: (b[3] - b[1]) / height } + } return { viewBox, + groups: groupBoxes, shapes: shapes.map(s => ({ ...s, points: s.points ? s.points.map(pt).join(' ') : undefined, @@ -147,3 +165,29 @@ export class IsoScene { } } } + +type Box = [number, number, number, number] + +function emptyBox (): Box { + return [Infinity, Infinity, -Infinity, -Infinity] +} + +function growBox (b: Box, [x0, y0, x1, y1]: Box) { + b[0] = Math.min(b[0], x0); b[1] = Math.min(b[1], y0) + b[2] = Math.max(b[2], x1); b[3] = Math.max(b[3], y1) +} + +// The 2D extent of one shape. Paths with arcs carry explicit bounds (the arcs sit inside +// the outline already counted); every other path is read from its own "x,y" pairs. +function shapeBox (s: Shape): Box | null { + const b = emptyBox() + const grow = (x: number, y: number) => growBox(b, [x, y, x, y]) + if (s.points) s.points.forEach(([x, y]) => grow(x, y)) + if (s.kind === 'circle') { grow(s.cx! - s.r!, s.cy! - s.r!); grow(s.cx! + s.r!, s.cy! + s.r!) } + if (s.bounds) growBox(b, s.bounds) + else if (s.d && !s.d.includes('A')) { + const nums = s.d.match(/-?\d+(\.\d+)?/g)?.map(Number) ?? [] + for (let i = 0; i + 1 < nums.length; i += 2) grow(nums[i], nums[i + 1]) + } + return b[0] === Infinity ? null : b +} From 442a9736c52294311bd3bb890b4bf82892f69a45 Mon Sep 17 00:00:00 2001 From: Kristopher Date: Fri, 13 Nov 2026 00:12:48 +0900 Subject: [PATCH 27/40] Refactor scene types and clean up functions Removed unused scene types and associated functions, and updated the HERO_GROUPS constant. --- nuxt/lib/home-iso-scenes.ts | 190 +++++++++++++++--------------------- 1 file changed, 77 insertions(+), 113 deletions(-) diff --git a/nuxt/lib/home-iso-scenes.ts b/nuxt/lib/home-iso-scenes.ts index 92a7e408ca..d54d4f769e 100644 --- a/nuxt/lib/home-iso-scenes.ts +++ b/nuxt/lib/home-iso-scenes.ts @@ -3,65 +3,99 @@ // scale doesn't matter, only its proportions. import { IsoScene, project } from './home-iso' -export type SceneName = 'hero' | 'sites' | 'foundation' | 'operations' | 'maintenance' | 'automation' | 'digital' | 'quality' | 'delivery' | 'cost' | 'uptime' +export type SceneName = 'hero' | 'sites' | 'foundation' | 'operations' | 'maintenance' | 'automation' | 'digital' // Flow lines are drawn above every solid, so data reads as travelling over the plant. const OVER = 1e5 -// A plant floor: edge gateway, PLC cabinet, three machines, a conveyor carrying -// product to a robot cell, two tanks, and every source wired up to one hub above. +// The six solution areas the hero steps through. Each is a tagged group in the hero +// scene; the page highlights one at a time and shows its readout (HomeHero.vue). +export const HERO_GROUPS = ['cnc', 'packaging', 'inspection', 'vibration', 'well', 'energy'] as const +export type HeroGroup = typeof HERO_GROUPS[number] + +// A plant floor: a PLC cabinet with an edge gateway, three machining centres, a +// packaging conveyor with a vision camera over it, a pump, a tank battery, and every +// source wired up to one hub above. The base drawing is all indigo, so the orange +// highlight the page puts on one solution at a time is the only warm colour. Each +// solution's group includes its own riser to the data bus, so its data path lights too. function hero () { const s = new IsoScene() s.floor(0, 0, 64, 44, 4) + const bus = 21 + const riser = (x: number, y: number, z: number) => + s.line([[x, y, z], [x, y, bus], [x, 6, bus]], 'line', 'flow', OVER + 1) - - // Three machining centres, each with a screen, a door and a status light + // Three machining centres, each with a screen, a door and a status light. + // The first is the CNC performance group. for (let i = 0; i < 3; i++) { const x = 9 + i * 13 - s.box(x, 8, 0, 10, 8, 9) - s.panelY(16, x + 1.5, x + 4.5, 4, 7.5, 'accent', x + 8 + 9.1) - s.panelY(16, x + 5.5, x + 9, 1, 7.5, 'muted', x + 8 + 9.1) - s.dot(x + 8.5, 9.5, 9, 0.6, i === 1 ? 'muted' : 'accent', x + 20) + const draw = () => { + s.box(x, 8, 0, 10, 8, 9) + s.panelY(16, x + 1.5, x + 4.5, 4, 7.5, 'line', x + 8 + 9.1) + s.panelY(16, x + 5.5, x + 9, 1, 7.5, 'muted', x + 8 + 9.1) + s.dot(x + 8.5, 9.5, 9, 0.6, 'muted', x + 20) + riser(x + 5, 12, 9) + } + if (i === 0) s.tag('cnc', draw) + else draw() } - // Conveyor: frame, legs, rollers and product - for (let k = 0; k < 6; k++) { - s.box(7 + k * 8, 24.4, 0, 0.8, 0.8, 2) - s.box(7 + k * 8, 28, 0, 0.8, 0.8, 2) - } - s.box(6, 24, 2, 44, 5, 1.2) - for (let k = 0; k < 22; k++) { - const x = 7 + k * 2 - s.line([[x, 24, 3.2], [x, 29, 3.2]], 'muted', 'none', 6 + 24 + 30) - } - // Product rides on the belt, so it's drawn after the belt and its rollers - for (let k = 0; k < 5; k++) s.box(9 + k * 7.5, 25, 3.2, 3, 3, 2.4, k === 2 ? 'accent' : 'line', 61 + k) + // Packaging conveyor: legs, belt, rollers, product, and a riser off the end + s.tag('packaging', () => { + for (let k = 0; k < 6; k++) { + s.box(7 + k * 8, 24.4, 0, 0.8, 0.8, 2) + s.box(7 + k * 8, 28, 0, 0.8, 0.8, 2) + } + s.box(6, 24, 2, 44, 5, 1.2) + for (let k = 0; k < 22; k++) { + const x = 7 + k * 2 + s.line([[x, 24, 3.2], [x, 29, 3.2]], 'muted', 'none', 6 + 24 + 30) + } + // Product rides on the belt, so it's drawn after the belt and its rollers + for (let k = 0; k < 5; k++) s.box(9 + k * 7.5, 25, 3.2, 3, 3, 2.4, 'line', 61 + k) + riser(49, 26.5, 3.2) + }) + + // Vision inspection: a gantry over the belt with a camera looking down at it. + // The back post sits behind the belt; the beam, camera and front post in front. + s.tag('inspection', () => { + s.box(30, 22.6, 0, 0.6, 0.6, 9) + s.box(30, 29.6, 0, 0.6, 0.6, 9, 'line', 70) + s.box(30, 22.6, 9, 0.6, 7.6, 0.6, 'line', 71) + s.box(29.4, 25.8, 6.8, 1.6, 1.6, 2.2, 'line', 72) + s.dot(31, 26.6, 7.6, 0.45, 'muted', 73) + riser(30.3, 26.2, 9.6) + }) - // Collection station at the end of the line - s.cylinder(55, 31, 0, 2.6, 3) + // Pump and motor at the end of the line: vibration condition monitoring + s.tag('vibration', () => { + s.cylinder(54, 31, 0, 2.4, 3.4, 'line', 1) + s.box(57, 29.8, 0, 3.6, 2.4, 2.4) + s.line([[56.3, 31, 1.4], [57, 31, 1.4]], 'line', 'none', 92) + riser(54, 31, 3.4) + }) - // Tanks and the pipe that feeds the line - s.cylinder(56, 9, 0, 4, 15, 'line', 2) - s.cylinder(56, 20, 0, 3, 10, 'line', 1) - s.line([[53.5, 20, 3], [52, 20, 3], [52, 24, 3]], 'line', 'none', 74) + // Tank battery and the pipe that feeds the line: well and plunger-lift optimisation + s.tag('well', () => { + s.cylinder(56, 9, 0, 4, 15, 'line', 2) + s.cylinder(56, 20, 0, 3, 10, 'line', 1) + s.line([[53.5, 20, 3], [52, 20, 3], [52, 24, 3]], 'line', 'none', 74) + riser(56, 9, 15) + }) - // PLC cabinet with edge gateway antenna, front left, I/O modules on its face - s.box(2, 30, 0, 3, 9, 8) - s.line([[3.5, 38, 8], [3.5, 38, 11]], 'line', 'none', 42) - s.dot(3.5, 38, 11, 0.6, 'accent', 43) - for (let m = 0; m < 4; m++) s.panelX(5, 31 + m * 2, 32.4 + m * 2, 2, 6.5, m === 0 ? 'accent' : 'muted', 40) + // PLC cabinet with an edge gateway antenna and I/O modules: remote energy service + s.tag('energy', () => { + s.box(2, 30, 0, 3, 9, 8) + s.line([[3.5, 38, 8], [3.5, 38, 11]], 'line', 'none', 42) + s.dot(3.5, 38, 11, 0.6, 'muted', 43) + for (let m = 0; m < 4; m++) s.panelX(5, 31 + m * 2, 32.4 + m * 2, 2, 6.5, 'muted', 40) + s.line([[3.5, 30, 8], [3.5, 30, bus], [3.5, 6, bus]], 'line', 'flow', OVER + 1) + }) - // Data: every source rises to a shared bus, and the bus feeds the hub - const bus = 21 + // The data bus and the hub every source feeds s.line([[3.5, 6, bus], [56, 6, bus]], 'muted', 'none', OVER) - const risers: [number, number, number][] = [[14, 12, 9], [27, 12, 9], [40, 12, 9], [56, 9, 15]] - for (const [x, y, z] of risers) s.line([[x, y, z], [x, y, bus], [x, 6, bus]], 'accent', 'flow', OVER + 1) - s.line([[3.5, 30, 8], [3.5, 30, bus], [3.5, 6, bus]], 'accent', 'flow', OVER + 1) - s.line([[30, 6, bus], [30, 6, 26]], 'accent', 'flow', OVER + 1) - s.box(27, 3, 26, 6, 6, 3, 'accent') - s.shapes[s.shapes.length - 1].depth = OVER + 2 - s.shapes[s.shapes.length - 2].depth = OVER + 2 - s.shapes[s.shapes.length - 3].depth = OVER + 2 + s.line([[30, 6, bus], [30, 6, 26]], 'line', 'flow', OVER + 1) + s.box(27, 3, 26, 6, 6, 3, 'line', OVER + 2) s.dot(30, 6, 29, 0.8, 'accent', OVER + 3) return s } @@ -191,77 +225,7 @@ function digital () { return s } -// Scorecard scenes: one picture per card on the "Results you can measure" section. -// Accent always marks the signal FlowFuse raises: the caught defect, the current -// hour, the stop being costed, the warning. - -// Quality: parts travel under an inspection camera; the defective one is flagged -// mid-line, before it reaches the end. -function quality () { - const s = new IsoScene() - for (let k = 0; k < 3; k++) { - s.box(0.5 + k * 8, 0.3, 0, 0.6, 0.6, 1.6, 'line', k) - s.box(0.5 + k * 8, 3.1, 0, 0.6, 0.6, 1.6, 'line', 10 + k) - } - s.box(0, 0, 1.6, 19, 4, 0.9, 'line', 20) - // Camera gantry: far post behind the belt, near post and beam in front - s.box(9, -1, 0, 0.7, 0.7, 7.5, 'line', 15) - s.box(1.5, 0.7, 2.5, 2.6, 2.6, 2.2, 'line', 30) - s.box(8.2, 0.7, 2.5, 2.6, 2.6, 2.2, 'accent', 31) - s.box(14.5, 0.7, 2.5, 2.6, 2.6, 2.2, 'line', 32) - s.box(9, 4.3, 0, 0.7, 0.7, 7.5, 'line', 40) - s.box(9, -1, 7.5, 0.7, 6, 0.8, 'line', 41) - // Camera head on the beam, looking down at the flagged part - s.box(8.8, 1.6, 6.3, 1.1, 1.1, 1.2, 'line', 42) - s.line([[9.35, 2.15, 6.3], [9.5, 2, 4.9]], 'accent', 'flow', 43) - return s -} - -// Delivery: a loaded truck leaving the dock, the finished goods it carries. -function delivery () { - const s = new IsoScene() - // Pallet of finished goods waiting on the dock - s.box(-6, 0.5, 0, 4.5, 4.5, 0.5, 'muted', -5) - s.box(-5.5, 1, 0.5, 1.8, 1.8, 1.6, 'line', -4) - s.box(-3.5, 1, 0.5, 1.8, 1.8, 1.6, 'line', -3) - s.box(-5.5, 3, 0.5, 1.8, 1.8, 1.6, 'line', -2) - // Cargo body, then the cab with its windscreen - s.box(0, 0, 1.2, 10, 5, 5.5, 'line', 10) - s.box(10, 0.4, 1.2, 3.6, 4.2, 3.8, 'accent', 11) - s.panelX(13.6, 1, 4, 3.2, 4.6, 'accent', 12) - // Wheels on the near side - for (const x of [2, 7.5, 11.8]) s.dot(x, 5.2, 1.1, 1.05, 'line', 30) - return s -} - -// Cost: three coin stacks, each lower than the last. -function cost () { - const s = new IsoScene() - s.box(-2, -2, 0, 18, 7, 0.5, 'muted', -10) - const stacks: [number, number][] = [[0, 8], [6, 5.2], [12, 2.6]] - stacks.forEach(([x, h], i) => { - s.cylinder(x + 1.5, 1.5, 0.5, 2, h, i === 2 ? 'accent' : 'line', Math.round(h / 1.3) - 1) - }) - return s -} - -// Uptime: a machine with a stack light on top; the top segment is lit, the warning -// that comes before a failure. -function uptime () { - const s = new IsoScene() - s.box(-1, -1, 0, 11, 9, 0.5, 'muted', -10) - s.box(0, 0, 0.5, 8, 6.5, 6.5, 'line', 1) - s.panelY(6.5, 1, 4.2, 2.5, 5.5, 'muted', 2) - s.panelY(6.5, 5, 7, 1.2, 5.5, 'muted', 2) - // Stack light: pole, two unlit segments, the lit one on top - s.cylinder(6, 2, 7, 0.35, 1.2, 'line') - s.cylinder(6, 2, 8.2, 1, 1.4, 'line') - s.cylinder(6, 2, 9.6, 1, 1.4, 'line') - s.cylinder(6, 2, 11, 1, 1.4, 'accent') - return s -} - -const BUILDERS: Record IsoScene> = { hero, sites, foundation, operations, maintenance, automation, digital, quality, delivery, cost, uptime } +const BUILDERS: Record IsoScene> = { hero, sites, foundation, operations, maintenance, automation, digital } const cache = new Map>() From 3954032b12e8ddf0e4893ce68b2d481fc6bfdfbf Mon Sep 17 00:00:00 2001 From: Kristopher Date: Fri, 13 Nov 2026 00:22:56 +0900 Subject: [PATCH 28/40] Update homepage component from HomeIsoArt to HomeHero Replaced HomeIsoArt component with HomeHero for improved homepage design. --- nuxt/pages/index.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nuxt/pages/index.vue b/nuxt/pages/index.vue index 3d7b96125d..50bb6d116f 100644 --- a/nuxt/pages/index.vue +++ b/nuxt/pages/index.vue @@ -1,6 +1,6 @@