diff --git a/monoscope.cabal b/monoscope.cabal index a52e8d54b..ae3cb0ced 100644 --- a/monoscope.cabal +++ b/monoscope.cabal @@ -177,6 +177,7 @@ library Pages.Dashboards Pages.Endpoints Pages.GitSync + Pages.Hardware Pages.LogExplorer.Log Pages.LogExplorer.LogItem Pages.Monitors @@ -549,7 +550,7 @@ executable monoscope-server TypeFamilies UndecidableInstances ViewPatterns - ghc-options: -fwrite-ide-info -threaded -Weverything -Werror -fno-defer-typed-holes -Wno-error=deprecations -Wno-error=unused-packages -Wno-error=implicit-lift -Wno-error=missing-poly-kind-signatures -Wno-missing-exported-signatures -Wno-missing-import-lists -Wno-missed-specialisations -Wno-all-missed-specialisations -Wno-unsafe -Wno-safe -Wno-missing-local-signatures -Wno-monomorphism-restriction -Wno-missing-safe-haskell-mode -Wno-partial-fields -Wno-prepositive-qualified-module -Wno-missing-export-lists -Wno-orphans -Wno-missing-deriving-strategies -Wno-missing-role-annotations -Wno-unused-matches -Wno-missing-kind-signatures -Wno-type-defaults -Wno-unused-top-binds -Wno-unused-imports -Wno-error=incomplete-record-selectors -Wno-ambiguous-fields -threaded -fplugin AutoInstrument -rtsopts "-with-rtsopts=-N8 -M14G -A32m -I5 -Fd2" + ghc-options: -fwrite-ide-info -threaded -Weverything -Werror -fno-defer-typed-holes -Wno-error=deprecations -Wno-error=unused-packages -Wno-error=implicit-lift -Wno-error=missing-poly-kind-signatures -Wno-missing-exported-signatures -Wno-missing-import-lists -Wno-missed-specialisations -Wno-all-missed-specialisations -Wno-unsafe -Wno-safe -Wno-missing-local-signatures -Wno-monomorphism-restriction -Wno-missing-safe-haskell-mode -Wno-partial-fields -Wno-prepositive-qualified-module -Wno-missing-export-lists -Wno-orphans -Wno-missing-deriving-strategies -Wno-missing-role-annotations -Wno-unused-matches -Wno-missing-kind-signatures -Wno-type-defaults -Wno-unused-top-binds -Wno-unused-imports -Wno-error=incomplete-record-selectors -Wno-ambiguous-fields -threaded -fplugin AutoInstrument -rtsopts "-with-rtsopts=-N8 -M14G -A32m -I5 -Iw60 -Fd2" build-tool-depends: proto-lens-protoc:proto-lens-protoc build-depends: @@ -779,6 +780,7 @@ test-suite test-dev Pages.Dashboards Pages.Endpoints Pages.GitSync + Pages.Hardware Pages.LogExplorer.Log Pages.LogExplorer.LogItem Pages.Monitors diff --git a/src/Pages/BodyWrapper.hs b/src/Pages/BodyWrapper.hs index df290eba5..fe0c736d7 100644 --- a/src/Pages/BodyWrapper.hs +++ b/src/Pages/BodyWrapper.hs @@ -59,6 +59,7 @@ menu lang pid = , (I18n.t lang "nav.issues", "/p/" <> pid.toText <> "/issues", "bug") , (I18n.t lang "nav.monitors", "/p/" <> pid.toText <> "/monitors", "list-check") , (I18n.t lang "nav.reports", "/p/" <> pid.toText <> "/reports", "chart-simple") + , ("Hardware", "/p/" <> pid.toText <> "/hardware", "objects-column") ] diff --git a/src/Pages/Hardware.hs b/src/Pages/Hardware.hs new file mode 100644 index 000000000..5b6068db9 --- /dev/null +++ b/src/Pages/Hardware.hs @@ -0,0 +1,31 @@ +module Pages.Hardware (hardwareGetH, HardwareGet (..)) where + +import Lucid +import Models.Projects.Projects qualified as Projects +import Pages.BodyWrapper (BWConfig (..), PageCtx (..), mkPageCtx) +import Relude +import System.Types (ATAuthCtx, RespHeaders, addRespHeaders) + + +-- | Hardware / digital-twin viewer page. Mounts the +-- custom element which owns the 3D scene, 2D fallback, scrubber, and rail. +newtype HardwareGet = HardwareGet {project :: Projects.Project} + deriving stock (Generic, Show) + + +instance ToHtml HardwareGet where + toHtmlRaw = toHtml + toHtml (HardwareGet project) = + div_ [class_ "w-full bg-bgBase", style_ "height:calc(100vh - 56px)"] + $ term + "hardware-monitor" + [ data_ "project-id" project.id.toText + , class_ "block w-full h-full" + ] + "" + + +hardwareGetH :: Projects.ProjectId -> ATAuthCtx (RespHeaders (PageCtx HardwareGet)) +hardwareGetH pid = do + (_, project, bw) <- mkPageCtx pid + addRespHeaders $ PageCtx bw{pageTitle = "Hardware", menuItem = Just "Hardware"} (HardwareGet project) diff --git a/src/Web/Routes.hs b/src/Web/Routes.hs index 2151046fd..1c09f0868 100644 --- a/src/Web/Routes.hs +++ b/src/Web/Routes.hs @@ -89,6 +89,7 @@ import Pages.Charts.Charts qualified as Charts import Pages.Dashboards qualified as Dashboards import Pages.Endpoints qualified as ApiCatalog import Pages.GitSync qualified as GitSync +import Pages.Hardware qualified as Hardware import Pages.LogExplorer.Log qualified as Log import Pages.LogExplorer.LogItem qualified as LogItem import Pages.Monitors qualified as Alerts @@ -418,6 +419,8 @@ data CookieProtectedRoutes mode = CookieProtectedRoutes , -- Command palette commandPaletteGet :: mode :- "p" :> ProjectId :> "command-palette" :> Get '[HTML] (RespHeaders (Html ())) -- dynamic items only , commandPaletteRecentPost :: mode :- "p" :> ProjectId :> "command-palette" :> "recents" :> ReqBody '[FormUrlEncoded] CommandPalette.RecentForm :> Post '[HTML] (RespHeaders NoContent) + , -- Hardware / digital twin + hardwareGet :: mode :- "p" :> ProjectId :> "hardware" :> Get '[HTML] (RespHeaders (PageCtx Hardware.HardwareGet)) , -- Device auth deviceApprove :: mode :- "device" :> QPT "code" :> QPT "action" :> Get '[HTML] (RespHeaders (Html ())) , -- Sub-route groups @@ -783,6 +786,8 @@ cookieProtectedServer = , -- Command palette commandPaletteGet = CommandPalette.commandPaletteItemsH , commandPaletteRecentPost = CommandPalette.commandPaletteRecentPostH + , -- Hardware + hardwareGet = Hardware.hardwareGetH , -- Device auth deviceApprove = Auth.deviceApproveH , -- Sub-route handlers diff --git a/web-components/package-lock.json b/web-components/package-lock.json index 61726a2d0..fc9286060 100644 --- a/web-components/package-lock.json +++ b/web-components/package-lock.json @@ -14,6 +14,7 @@ "@rrweb/replay": "^2.0.0", "@rrweb/types": "^2.0.0-alpha.18", "@types/lodash": "^4.17.24", + "@types/three": "^0.184.1", "ansi_up": "^6.0.6", "ansi-up": "^1.0.0", "clsx": "^2.1.1", @@ -24,6 +25,7 @@ "lit": "^3.3.3", "lodash": "^4.17.23", "monaco-editor": "^0.55.1", + "three": "^0.184.0", "vite": "^8.0.14" }, "devDependencies": { @@ -198,6 +200,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -246,30 +249,16 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "license": "Apache-2.0" }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", @@ -932,6 +921,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", @@ -1001,11 +996,37 @@ "undici-types": ">=7.24.0 <7.24.7" } }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.184.1", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.184.1.tgz", + "integrity": "sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA==", + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "fflate": "~0.8.2", + "meshoptimizer": "~1.1.1" + } + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==" }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "license": "MIT" + }, "node_modules/@types/whatwg-mimetype": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", @@ -2002,6 +2023,12 @@ } } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -2424,6 +2451,7 @@ "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@asamuzakjp/css-color": "^5.1.11", "@asamuzakjp/dom-selector": "^7.1.1", @@ -2827,6 +2855,12 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/meshoptimizer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz", + "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==", + "license": "MIT" + }, "node_modules/miller-rabin": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/miller-rabin/-/miller-rabin-4.0.1.tgz", @@ -2870,6 +2904,7 @@ "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", "license": "MIT", + "peer": true, "dependencies": { "dompurify": "3.2.7", "marked": "14.0.0" @@ -3745,6 +3780,12 @@ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", "dev": true }, + "node_modules/three": { + "version": "0.184.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz", + "integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==", + "license": "MIT" + }, "node_modules/timers-browserify": { "version": "2.0.12", "resolved": "https://registry.npmjs.org/timers-browserify/-/timers-browserify-2.0.12.tgz", @@ -3987,6 +4028,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -4091,6 +4133,7 @@ "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.1.7", "@vitest/mocker": "4.1.7", diff --git a/web-components/package.json b/web-components/package.json index d128b8fc7..523298fa4 100644 --- a/web-components/package.json +++ b/web-components/package.json @@ -22,8 +22,9 @@ "@rrweb/replay": "^2.0.0", "@rrweb/types": "^2.0.0-alpha.18", "@types/lodash": "^4.17.24", - "ansi-up": "^1.0.0", + "@types/three": "^0.184.1", "ansi_up": "^6.0.6", + "ansi-up": "^1.0.0", "clsx": "^2.1.1", "date-fns": "^4.3.0", "echarts": "^6.1.0", @@ -32,6 +33,7 @@ "lit": "^3.3.3", "lodash": "^4.17.23", "monaco-editor": "^0.55.1", + "three": "^0.184.0", "vite": "^8.0.14" }, "devDependencies": { diff --git a/web-components/src/hardware-monitor.ts b/web-components/src/hardware-monitor.ts new file mode 100644 index 000000000..61a926d66 --- /dev/null +++ b/web-components/src/hardware-monitor.ts @@ -0,0 +1,479 @@ +// — digital-twin POC for the Hardware page. +// +// Owns: telemetry generation, time cursor, play/pause/speed, view mode (3D|2D), +// active joint + metric, sparkline rendering, timeline scrubber, anomaly list. +// Delegates visuals to scene.ts (three.js procedural arm) and schematic2d.ts +// (SVG fallback). Both expose the same setJointAngles/setJointStates/onJointClick +// API so swapping views is a drop-in replacement. + +import { html, LitElement, PropertyValues, TemplateResult } from 'lit'; +import { query, state } from 'lit/decorators.js'; +import { + generateTelemetry, + sampleAt, + severity, + METRICS, + METRIC_KINDS, + JOINT_IDS, + SEVERITY_COLORS, + type JointId, + type MetricKind, + type Severity, + type Telemetry, + type TimelineEvent, +} from './hardware/data'; +// Static imports — dynamic imports create chunks that re-execute the main +// bundle (cache-bust ?v= mismatch) and crash on re-registering custom elements. +import { buildScene, type SceneAPI } from './hardware/scene'; +import { buildSchematic, type SchematicAPI } from './hardware/schematic2d'; + +type ViewMode = '3d' | '2d'; +type ViewAPI = SceneAPI | SchematicAPI; + +export class HardwareMonitor extends LitElement { + // Lit shadow DOM hides Tailwind — we render to light DOM so global CSS applies + // and ECharts (loaded globally) can find canvases by ID. + protected createRenderRoot() { return this; } + + projectId = ''; + + @state() private telemetry: Telemetry | null = null; + @state() private currentTime = 0; // seconds from start + @state() private playing = false; + @state() private speed: 1 | 2 | 4 | 8 | 16 = 4; + @state() private view: ViewMode = '3d'; + @state() private activeMetric: MetricKind = 'temp'; + @state() private activeJoint: JointId = 3; + @state() private headerSeverity: Severity = 'ok'; + + @query('#hw-stage') private stage!: HTMLDivElement; + @query('#hw-timeline') private timelineEl!: HTMLDivElement; + @query('#hw-spark-host') private sparkHost!: HTMLDivElement; + + private viewApi: ViewAPI | null = null; + private timelineChart: any = null; + private sparkCharts: Record = {} as any; + private rafId = 0; + private lastTickMs = 0; + private resizeObs: ResizeObserver | null = null; + + connectedCallback() { + super.connectedCallback(); + this.projectId = this.dataset.projectId ?? ''; + this.telemetry = generateTelemetry(); + // Start near the start of the incident so the demo lands fast + this.currentTime = 120; + } + + disconnectedCallback() { + super.disconnectedCallback(); + cancelAnimationFrame(this.rafId); + this.viewApi?.dispose?.(); + this.timelineChart?.dispose?.(); + Object.values(this.sparkCharts).forEach((c: any) => c?.dispose?.()); + this.resizeObs?.disconnect(); + } + + protected firstUpdated(_: PropertyValues): void { + this.mountView(); + this.mountTimeline(); + this.mountSparklines(); + this.refreshAll(); + } + + protected updated(changed: PropertyValues): void { + if (changed.has('view')) this.mountView(); + if (changed.has('activeJoint')) { + this.viewApi?.setHighlight(this.activeJoint); + this.updateSparklines(); + } + if (changed.has('activeMetric')) { + this.refreshAll(); + this.updateTimelineSeries(); + } + } + + // ─── View (3D / 2D) ───────────────────────────────────────────────────── + + private async mountView() { + if (!this.stage) return; + this.viewApi?.dispose?.(); + this.viewApi = null; + this.stage.innerHTML = ''; + if (this.view === '3d') { + this.viewApi = await buildScene(this.stage); + } else { + this.viewApi = buildSchematic(this.stage); + } + this.viewApi.onJointClick((id) => { this.activeJoint = id; }); + this.viewApi.setHighlight(this.activeJoint); + this.refreshAll(); + } + + // ─── Timeline (ECharts) ───────────────────────────────────────────────── + + private mountTimeline() { + const ec = (window as any).echarts; + if (!ec || !this.timelineEl || !this.telemetry) return; + this.timelineChart = ec.init(this.timelineEl, null, { renderer: 'canvas' }); + this.updateTimelineSeries(); + + this.timelineChart.on('click', (p: any) => { + if (typeof p.value?.[0] === 'number') this.scrubTo(p.value[0]); + }); + // Click on grid scrubs too + this.timelineEl.addEventListener('click', (ev) => { + const rect = this.timelineEl.getBoundingClientRect(); + const xPct = (ev.clientX - rect.left) / rect.width; + const t = xPct * this.telemetry!.durationSec; + this.scrubTo(t); + }); + } + + private updateTimelineSeries() { + if (!this.timelineChart || !this.telemetry) return; + const tel = this.telemetry; + const isDark = document.documentElement.dataset.theme === 'dark'; + const axisColor = isDark ? '#9aa4b0' : '#5b6573'; + const gridColor = isDark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.06)'; + + // Distinct per-joint palette — picks colors that survive both themes. + const jointColors = ['#3b82f6', '#10b981', '#f59e0b', '#a855f7', '#ec4899', '#06b6d4']; + + // One line per joint at the active metric, plus event markers + const series = JOINT_IDS.map((j, i) => ({ + name: `J${j}`, + type: 'line', + symbol: 'none', + smooth: true, + lineStyle: { + width: j === this.activeJoint ? 2.6 : 1.4, + color: jointColors[i], + opacity: j === this.activeJoint ? 1 : 0.75, + }, + emphasis: { focus: 'series' }, + data: Array.from(tel.joints[j][this.activeMetric]).map((v, i2) => [i2 / tel.hz, v]), + })); + + const meta = METRICS[this.activeMetric]; + // Render second-of-cycle as mm:ss for legibility. + const fmtSec = (s: number) => { + const mm = Math.floor(s / 60).toString().padStart(2, '0'); + const ss = Math.floor(s % 60).toString().padStart(2, '0'); + return `${mm}:${ss}`; + }; + + this.timelineChart.setOption({ + animation: false, + grid: { top: 22, left: 56, right: 20, bottom: 38, containLabel: false }, + tooltip: { + trigger: 'axis', confine: true, + backgroundColor: isDark ? 'rgba(20,24,30,0.95)' : 'rgba(255,255,255,0.97)', + borderColor: isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.08)', + textStyle: { color: isDark ? '#e6eaf0' : '#1a1f26', fontSize: 12 }, + axisPointer: { type: 'line', lineStyle: { color: axisColor, type: 'dashed' } }, + }, + xAxis: { + type: 'value', min: 0, max: tel.durationSec, + axisLabel: { fontSize: 11, color: axisColor, formatter: fmtSec }, + axisLine: { lineStyle: { color: axisColor } }, + splitLine: { show: true, lineStyle: { color: gridColor } }, + splitNumber: 6, + }, + yAxis: { + type: 'value', min: meta.yMin, max: meta.yMax, name: meta.unit, nameTextStyle: { color: axisColor, fontSize: 11 }, + axisLabel: { fontSize: 11, color: axisColor }, + axisLine: { lineStyle: { color: axisColor } }, + splitLine: { show: true, lineStyle: { color: gridColor } }, + }, + legend: { show: false }, + series: [ + ...series, + // Event markers — pin glyph on a dedicated z so they sit on top + { + name: 'events', type: 'scatter', symbolSize: 16, + data: tel.events.map((e: TimelineEvent) => ({ + value: [e.t, METRICS[e.metric].yMax * 0.92], + itemStyle: { color: SEVERITY_COLORS[e.severity], borderColor: '#fff', borderWidth: 1.5 }, + symbol: 'pin', symbolRotate: 180, + __event: e, + })), + tooltip: { formatter: (p: any) => `${p.data.__event.title}
${p.data.__event.detail}` }, + z: 5, + }, + // Current cursor — vertical line via markLine + { + name: '__cursor', type: 'line', data: [], + markLine: { + silent: true, symbol: ['none', 'circle'], symbolSize: 8, + lineStyle: { color: '#ef4444', width: 2, type: 'solid' }, + label: { + show: true, position: 'insideEndTop', + backgroundColor: '#ef4444', color: '#fff', padding: [2, 5], + borderRadius: 3, fontSize: 11, fontWeight: 'bold', + formatter: () => fmtSec(this.currentTime), + }, + data: [{ xAxis: this.currentTime }], + }, + }, + ], + }); + } + + private setCursor() { + if (!this.timelineChart) return; + // Mutate the cursor line in-place — cheap + this.timelineChart.setOption({ + series: [ + ...new Array(JOINT_IDS.length + 1).fill({}), + { name: '__cursor', markLine: { data: [{ xAxis: this.currentTime }] } }, + ], + }); + } + + // ─── Sparklines (per-joint mini ECharts) ──────────────────────────────── + + private mountSparklines() { + const ec = (window as any).echarts; + if (!ec || !this.sparkHost) return; + for (const m of METRIC_KINDS) { + const el = this.sparkHost.querySelector(`[data-spark="${m}"]`) as HTMLElement; + if (el) this.sparkCharts[m] = ec.init(el, null, { renderer: 'canvas' }); + } + this.updateSparklines(); + } + + private updateSparklines() { + if (!this.telemetry) return; + const tel = this.telemetry; + const j = this.activeJoint; + for (const m of METRIC_KINDS) { + const chart = this.sparkCharts[m]; + if (!chart) continue; + const data = Array.from(tel.joints[j][m]); + const meta = METRICS[m]; + chart.setOption({ + grid: { top: 6, left: 4, right: 4, bottom: 4 }, + xAxis: { type: 'value', min: 0, max: tel.durationSec, show: false }, + yAxis: { type: 'value', min: meta.yMin, max: meta.yMax, show: false }, + series: [ + { + type: 'line', symbol: 'none', smooth: true, + lineStyle: { width: 1.5, color: '#2a6df5' }, + areaStyle: { color: 'rgba(42,109,245,0.12)' }, + data: data.map((v, i) => [i / tel.hz, v]), + }, + { + type: 'line', data: [], + markLine: { + silent: true, symbol: ['none', 'none'], + lineStyle: { color: '#dc2626', width: 1.2 }, + label: { show: false }, + data: [{ xAxis: this.currentTime }], + }, + }, + ], + }); + } + } + + // ─── Time + state refresh ─────────────────────────────────────────────── + + private refreshAll() { + if (!this.telemetry || !this.viewApi) return; + const tel = this.telemetry; + + const angles = {} as Record; + const states = {} as Record; + const values = {} as Record; + let worst: Severity = 'ok'; + + for (const j of JOINT_IDS) { + angles[j] = sampleAt(tel.joints[j].position, tel.hz, this.currentTime); + const v = sampleAt(tel.joints[j][this.activeMetric], tel.hz, this.currentTime); + const sev = severity(this.activeMetric, v); + states[j] = sev; + values[j] = `${v.toFixed(1)}${METRICS[this.activeMetric].unit}`; + if (sev === 'crit') worst = 'crit'; + else if (sev === 'warn' && worst === 'ok') worst = 'warn'; + } + this.viewApi.setJointAngles(angles); + this.viewApi.setJointStates(states); + this.viewApi.setJointValues(values); + this.headerSeverity = worst; + this.setCursor(); + // Update sparkline cursors + for (const m of METRIC_KINDS) { + this.sparkCharts[m]?.setOption({ + series: [{}, { markLine: { data: [{ xAxis: this.currentTime }] } }], + }); + } + } + + private scrubTo(t: number) { + if (!this.telemetry) return; + this.currentTime = Math.max(0, Math.min(this.telemetry.durationSec, t)); + this.refreshAll(); + } + + // ─── Play loop ────────────────────────────────────────────────────────── + + private togglePlay = () => { + this.playing = !this.playing; + if (this.playing) { + // If we're at the end, rewind + if (this.telemetry && this.currentTime >= this.telemetry.durationSec - 0.5) this.currentTime = 0; + this.lastTickMs = performance.now(); + this.rafId = requestAnimationFrame(this.tick); + } else { + cancelAnimationFrame(this.rafId); + } + }; + + private tick = (now: number) => { + if (!this.playing || !this.telemetry) return; + const dt = (now - this.lastTickMs) / 1000; + this.lastTickMs = now; + this.currentTime += dt * this.speed; + if (this.currentTime >= this.telemetry.durationSec) { + this.currentTime = this.telemetry.durationSec; + this.playing = false; + } + this.refreshAll(); + if (this.playing) this.rafId = requestAnimationFrame(this.tick); + }; + + private setSpeed = (s: 1 | 2 | 4 | 8 | 16) => { this.speed = s; }; + + private jumpToEvent = (e: TimelineEvent) => { + this.activeJoint = e.joint; + this.activeMetric = e.metric; + this.scrubTo(Math.max(0, e.t - 8)); + }; + + // ─── Render ───────────────────────────────────────────────────────────── + + protected render(): TemplateResult { + const tel = this.telemetry; + if (!tel) return html`
Loading telemetry…
`; + + const ct = this.currentTime; + const formatT = (s: number) => { + const mm = Math.floor(s / 60).toString().padStart(2, '0'); + const ss = Math.floor(s % 60).toString().padStart(2, '0'); + return `${mm}:${ss}`; + }; + + return html` +
+ +
+
+ + UR5-Demo · Cell A3 + 6-axis arm · pick-and-place +
+
+ ${(['3d', '2d'] as const).map(v => html` + + `)} +
+
+ + +
+
+ + +
+ + +
+
+ + ${formatT(ct)} / ${formatT(tel.durationSec)} +
+ ${([1, 2, 4, 8, 16] as const).map(s => html` + + `)} +
+ ${METRICS[this.activeMetric].label} +
+
+
+
+ `; + } +} + diff --git a/web-components/src/hardware/binding.ts b/web-components/src/hardware/binding.ts new file mode 100644 index 000000000..0eab7aaf1 --- /dev/null +++ b/web-components/src/hardware/binding.ts @@ -0,0 +1,120 @@ +// Binding adapter — the seam between the visual layer and the data source. +// +// Today the hardware-monitor reads from generateTelemetry() (synthetic). The +// next step is to swap that for a real monoscope KQL query: one query per +// metric, one series per joint, sampled at the scrubber's resolution. +// +// This file does NOT yet wire to KQL — it's a contract sketch so the +// next change can drop in an adapter and the component stays unchanged. +// +// Expected real-world shape: KQL produces a result that we coerce into the +// same `Telemetry` shape generateTelemetry() returns. Sketch query (per +// joint, per metric): +// +// resource.service.name == "ur5-cell-a3" +// | where attributes.joint_id == 3 and metric.name == "joint.temperature" +// | summarize avg(value) by bin(timestamp, 1s) +// | order by timestamp asc +// +// One KQL call per metric, fan out across joints in Promise.all. Events +// (the synthetic anomalies) would come from monoscope alert monitors +// keyed on the same dimensions. + +import type { JointId, JointSeries, MetricKind, Telemetry, TimelineEvent } from './data'; +import { JOINT_IDS, METRIC_KINDS } from './data'; + +export interface TelemetryBinding { + /** Project ID, used to scope queries and alerts. */ + projectId: string; + /** Machine identifier; in the demo this is the cell+arm slug. */ + machineId: string; + /** Wall-clock window for the displayed timeline. */ + from: Date; + to: Date; + /** Sample rate (Hz) for downsampling KQL output to a Float32Array. */ + hz: number; +} + +export interface TelemetrySource { + load(b: TelemetryBinding): Promise; +} + +/** + * Stub that returns synthetic telemetry. Replace with a real KQL adapter + * when the API is ready. Kept as a class so the component can be passed a + * source instead of calling generateTelemetry() directly. + */ +export class SyntheticSource implements TelemetrySource { + constructor(private generate: (opts?: { durationSec?: number; hz?: number; startMs?: number }) => Telemetry) {} + async load(b: TelemetryBinding): Promise { + const durationSec = Math.max(1, Math.round((b.to.getTime() - b.from.getTime()) / 1000)); + return this.generate({ durationSec, hz: b.hz, startMs: b.from.getTime() }); + } +} + +/** + * Future shape for the KQL-backed source. Not implemented — kept here so + * the next change can fill in `runKql` and the component reads identically. + */ +export class KqlSource implements TelemetrySource { + constructor(private runKql: (query: string, b: TelemetryBinding) => Promise<{ t: number; v: number }[]>) {} + + async load(b: TelemetryBinding): Promise { + const samples = b.hz * Math.max(1, Math.round((b.to.getTime() - b.from.getTime()) / 1000)); + const joints = {} as Record; + + // Fan out one query per (joint, metric). In practice we'd batch with + // `summarize ... by joint_id` to get fewer round-trips. + await Promise.all( + JOINT_IDS.flatMap((j) => + METRIC_KINDS.map(async (m) => { + if (!joints[j]) { + joints[j] = { + temp: new Float32Array(samples), + current: new Float32Array(samples), + vibration: new Float32Array(samples), + torque: new Float32Array(samples), + position: new Float32Array(samples), + }; + } + const series = await this.runKql(this.queryFor(j, m, b), b); + this.fillSeries(joints[j][m], series, b); + }) + ) + ); + + // Position would come from a `joint.position` metric; events from the + // alert monitor list. Both omitted in the stub. + return { + durationSec: samples / b.hz, + hz: b.hz, + samples, + startMs: b.from.getTime(), + joints, + events: [] as TimelineEvent[], + }; + } + + private queryFor(joint: JointId, metric: MetricKind, b: TelemetryBinding): string { + const metricName = `joint.${metric === 'temp' ? 'temperature' : metric}`; + return ` + metric.name == "${metricName}" + and resource.machine_id == "${b.machineId}" + and attributes.joint_id == ${joint} + | summarize avg(value) by bin(timestamp, ${Math.round(1000 / b.hz)}ms) + | order by timestamp asc + `.trim(); + } + + private fillSeries(out: Float32Array, samples: { t: number; v: number }[], b: TelemetryBinding) { + if (samples.length === 0) return; + const startMs = b.from.getTime(); + for (let i = 0; i < out.length; i++) { + const tMs = startMs + (i * 1000) / b.hz; + // Linear search OK at POC scale; switch to binary search for >10k points. + let k = 0; + while (k + 1 < samples.length && samples[k + 1].t <= tMs) k++; + out[i] = samples[k].v; + } + } +} diff --git a/web-components/src/hardware/data.ts b/web-components/src/hardware/data.ts new file mode 100644 index 000000000..4307cbe5f --- /dev/null +++ b/web-components/src/hardware/data.ts @@ -0,0 +1,264 @@ +// Synthetic telemetry for the hardware-monitor POC. +// +// Generates ~5 min @ 1 Hz of per-joint signals for a 6-axis arm running a +// pick-and-place loop, with a scripted incident: J3 over-temp climb from +// t=140s peaking at t=180s, and a J5 vibration spike at t=210s. +// +// The shape mirrors what real telemetry from monoscope/OTLP would look like +// once we bind to KQL — same names, same dimensions, swap the source. + +export type JointId = 1 | 2 | 3 | 4 | 5 | 6; +export type MetricKind = 'temp' | 'current' | 'vibration' | 'torque'; + +export const JOINT_IDS: JointId[] = [1, 2, 3, 4, 5, 6]; +export const METRIC_KINDS: MetricKind[] = ['temp', 'current', 'vibration', 'torque']; + +export interface MetricMeta { + label: string; + unit: string; + // thresholds for color mapping + warn: number; + crit: number; + // visible y-axis range for sparklines + yMin: number; + yMax: number; +} + +export const METRICS: Record = { + temp: { label: 'Temperature', unit: '°C', warn: 65, crit: 80, yMin: 30, yMax: 100 }, + current: { label: 'Current', unit: 'A', warn: 4.5, crit: 6, yMin: 0, yMax: 8 }, + vibration: { label: 'Vibration', unit: 'mm/s', warn: 3.0, crit: 5.0, yMin: 0, yMax: 8 }, + torque: { label: 'Torque', unit: 'Nm', warn: 35, crit: 50, yMin: 0, yMax: 60 }, +}; + +export type Severity = 'ok' | 'warn' | 'crit'; + +export function severity(metric: MetricKind, v: number): Severity { + const m = METRICS[metric]; + if (v >= m.crit) return 'crit'; + if (v >= m.warn) return 'warn'; + return 'ok'; +} + +export interface JointSeries { + temp: Float32Array; + current: Float32Array; + vibration: Float32Array; + torque: Float32Array; + // position in radians, drives geometry rotation + position: Float32Array; +} + +export interface TimelineEvent { + t: number; // seconds from start + joint: JointId; + metric: MetricKind; + severity: Severity; + title: string; + detail: string; +} + +export interface Telemetry { + durationSec: number; + hz: number; + samples: number; + // wall-clock start (only used for display) + startMs: number; + joints: Record; + events: TimelineEvent[]; +} + +// Linear ramp helpers +const lerp = (a: number, b: number, t: number) => a + (b - a) * Math.max(0, Math.min(1, t)); +const smooth = (a: number, b: number, t: number) => { + const x = Math.max(0, Math.min(1, t)); + return a + (b - a) * x * x * (3 - 2 * x); +}; + +// Tuned per-joint nominal baselines for the NON-position metrics. +// J1 = base yaw, J2/J3 = shoulder/elbow (high load), J4-J6 = wrist (low load). +const NOMINAL = { + 1: { temp: 38, current: 1.2, vibration: 0.6, torque: 12 }, + 2: { temp: 42, current: 2.8, vibration: 0.8, torque: 28 }, + 3: { temp: 45, current: 3.2, vibration: 0.9, torque: 32 }, + 4: { temp: 36, current: 0.9, vibration: 0.5, torque: 6 }, + 5: { temp: 35, current: 0.8, vibration: 0.7, torque: 5 }, + 6: { temp: 34, current: 0.4, vibration: 0.4, torque: 2 }, +} satisfies Record; + +// Cycle period in seconds — one pick-and-place pass. +const CYCLE = 12; + +// Pick-and-place waypoint program — what an industrial robot actually does. +// Each waypoint is (cycle-seconds, [J1..J6 angles in radians]). The arm +// moves linearly in joint space between adjacent waypoints with an S-curve +// (ease-in-out) so accelerations are smooth instead of bang-bang. +// Dwell waypoints (same angles, time apart) hold the pose while the +// (imaginary) gripper opens/closes. +const WAYPOINTS: { t: number; q: [number, number, number, number, number, number] }[] = [ + { t: 0.0, q: [ 0.0, -0.7, 1.3, 0.0, 0.0, 0.0] }, // home + { t: 1.5, q: [ 0.9, -0.7, 1.3, 0.0, 0.0, 0.0] }, // base rotate over pick + { t: 2.8, q: [ 0.9, -0.3, 1.7, -0.5, 0.0, 0.0] }, // descend to pick + { t: 3.6, q: [ 0.9, -0.3, 1.7, -0.5, 0.0, 0.0] }, // dwell — gripper close + { t: 4.6, q: [ 0.9, -0.7, 1.3, 0.0, 0.0, 0.0] }, // ascend + { t: 5.5, q: [ 0.0, -0.9, 1.0, 0.2, 0.6, 0.0] }, // transit waypoint + { t: 6.5, q: [-0.9, -0.7, 1.3, 0.0, 0.6, 1.2] }, // base rotate over place, wrist rolled + { t: 7.8, q: [-0.9, -0.3, 1.7, -0.5, 0.6, 1.2] }, // descend to place + { t: 8.6, q: [-0.9, -0.3, 1.7, -0.5, 0.6, 1.2] }, // dwell — gripper open + { t: 9.6, q: [-0.9, -0.7, 1.3, 0.0, 0.6, 1.2] }, // ascend + { t: 11.0, q: [ 0.0, -0.9, 1.0, 0.2, 0.0, 0.0] }, // transit back + { t: 12.0, q: [ 0.0, -0.7, 1.3, 0.0, 0.0, 0.0] }, // home (closes the cycle) +]; + +// S-curve interpolation: x ∈ [0,1] → eased ∈ [0,1] with zero velocity at ends. +const sCurve = (x: number) => { + const t = Math.max(0, Math.min(1, x)); + return t * t * (3 - 2 * t); +}; + +function jointAnglesAt(tSec: number): [number, number, number, number, number, number] { + // Modulo into one cycle + const t = ((tSec % CYCLE) + CYCLE) % CYCLE; + let i = 0; + while (i + 1 < WAYPOINTS.length && WAYPOINTS[i + 1].t <= t) i++; + const a = WAYPOINTS[i]; + const b = WAYPOINTS[Math.min(i + 1, WAYPOINTS.length - 1)]; + const dt = Math.max(1e-6, b.t - a.t); + const u = sCurve((t - a.t) / dt); + const out: [number, number, number, number, number, number] = [0, 0, 0, 0, 0, 0]; + for (let k = 0; k < 6; k++) out[k] = a.q[k] + (b.q[k] - a.q[k]) * u; + return out; +} + +// Joint velocity (rad/s) at time tSec — drives current/torque/vibration so +// the metrics react to actual motion, not just elapsed time. +function jointVelocitiesAt(tSec: number): [number, number, number, number, number, number] { + const h = 0.05; + const a = jointAnglesAt(tSec - h); + const b = jointAnglesAt(tSec + h); + const out: [number, number, number, number, number, number] = [0, 0, 0, 0, 0, 0]; + for (let k = 0; k < 6; k++) out[k] = (b[k] - a[k]) / (2 * h); + return out; +} + +// Deterministic pseudo-noise so the demo replays identically across reloads. +function noise(seed: number) { + let s = seed >>> 0; + return () => { + s = (s * 1664525 + 1013904223) >>> 0; + return ((s >>> 8) / 0x01000000) - 0.5; // [-0.5, 0.5) + }; +} + +export function generateTelemetry(opts?: { durationSec?: number; hz?: number; startMs?: number }): Telemetry { + const durationSec = opts?.durationSec ?? 300; + const hz = opts?.hz ?? 1; + const samples = durationSec * hz; + const startMs = opts?.startMs ?? (Date.now() - durationSec * 1000); + + const joints = {} as Record; + const rnd = noise(42); + + for (const j of JOINT_IDS) { + joints[j] = { + temp: new Float32Array(samples), + current: new Float32Array(samples), + vibration: new Float32Array(samples), + torque: new Float32Array(samples), + position: new Float32Array(samples), + }; + } + + for (let i = 0; i < samples; i++) { + const t = i / hz; // seconds + + // Scripted incident envelopes + const j3HeatRamp = smooth(0, 1, (t - 140) / 40); // 0→1 between 140s and 180s + const j3StallEnvelope = t >= 175 && t <= 195 ? smooth(0, 1, (t - 175) / 5) * smooth(1, 0, (t - 190) / 5) : 0; + const j5VibSpike = Math.exp(-Math.pow((t - 210) / 4, 2)); + + // Programmed joint angles + velocities from the waypoint program. During + // the stall window, freeze J3 in place to simulate a position-tracking + // error (motor commanded to move, joint won't). + const targetAngles = jointAnglesAt(t); + const velocities = jointVelocitiesAt(t); + if (j3StallEnvelope > 0.05) { + // Hold J3 at its position from the start of the stall window + const holdAngle = jointAnglesAt(175)[2]; + targetAngles[2] = holdAngle; + // Velocity still reads as commanded → torque spikes (motor pushing into a stuck joint) + } + + for (const j of JOINT_IDS) { + const n = NOMINAL[j]; + const angVel = Math.abs(velocities[j - 1]); + + joints[j].position[i] = targetAngles[j - 1]; + + // Current: proportional to |angular velocity| + load + noise + let current = n.current + angVel * 0.8 + rnd() * 0.08; + if (j === 3) current += j3HeatRamp * 2.2 + j3StallEnvelope * 1.8; + joints[j].current[i] = current; + + // Torque tracks current with load multiplier + let torque = n.torque + angVel * 7 + rnd() * 0.7; + if (j === 3) torque += j3HeatRamp * 12 + j3StallEnvelope * 12; + joints[j].torque[i] = torque; + + // Vibration: low baseline + cycle modulation + noise; J5 has the spike + let vib = n.vibration + angVel * 0.25 + Math.abs(rnd()) * 0.35; + if (j === 5) vib += j5VibSpike * 5.5; + if (j === 3) vib += j3StallEnvelope * 2.5; + joints[j].vibration[i] = vib; + + // Temp: slow thermal model — integrates current^2 with cooling + const prev = i > 0 ? joints[j].temp[i - 1] : n.temp; + const heatIn = current * current * 0.04; + const cool = (prev - n.temp) * 0.015; + let temp = prev + heatIn - cool; + // J3 incident: extra heat injection + if (j === 3) temp += j3HeatRamp * 0.35; + joints[j].temp[i] = temp; + } + } + + const events: TimelineEvent[] = [ + { t: 152, joint: 3, metric: 'temp', severity: 'warn', title: 'J3 temperature rising', + detail: 'Joint 3 (elbow) crossed 65°C while load profile is nominal.' }, + { t: 172, joint: 3, metric: 'temp', severity: 'crit', title: 'J3 over-temperature', + detail: 'Joint 3 exceeded 80°C critical threshold. Thermal cutoff imminent.' }, + { t: 180, joint: 3, metric: 'torque', severity: 'crit', title: 'J3 motion stall', + detail: 'Position tracking error spiked — joint 3 hitching under load.' }, + { t: 210, joint: 5, metric: 'vibration', severity: 'crit', title: 'J5 vibration spike', + detail: 'Wrist roll vibration exceeded 5 mm/s — possible bearing damage.' }, + ]; + + return { durationSec, hz, samples, startMs, joints, events }; +} + +// Sample a joint metric at fractional time (linear interp). +export function sampleAt(s: Float32Array, hz: number, t: number): number { + const idx = t * hz; + const i0 = Math.max(0, Math.min(s.length - 1, Math.floor(idx))); + const i1 = Math.min(s.length - 1, i0 + 1); + const frac = idx - i0; + return s[i0] * (1 - frac) + s[i1] * frac; +} + +// Aggregate severity across joints at time t for a metric — drives header health. +export function aggregateSeverity(tel: Telemetry, metric: MetricKind, t: number): Severity { + let worst: Severity = 'ok'; + for (const j of JOINT_IDS) { + const v = sampleAt(tel.joints[j][metric], tel.hz, t); + const s = severity(metric, v); + if (s === 'crit') return 'crit'; + if (s === 'warn') worst = 'warn'; + } + return worst; +} + +export const SEVERITY_COLORS: Record = { + ok: '#16a34a', // green-600 + warn: '#f59e0b', // amber-500 + crit: '#dc2626', // red-600 +}; diff --git a/web-components/src/hardware/scene.ts b/web-components/src/hardware/scene.ts new file mode 100644 index 000000000..a18af7823 --- /dev/null +++ b/web-components/src/hardware/scene.ts @@ -0,0 +1,312 @@ +// 3D scene for the hardware-monitor. Builds a procedural 6-axis arm out of +// primitives so we have zero asset dependency for the POC. Joints are named +// `joint_1`..`joint_6` and parented in a chain — same shape a GLTF UR5 would +// have, so swapping in a real CAD model later only changes loadModel(). + +import type { JointId, Severity } from './data'; +import { SEVERITY_COLORS } from './data'; +import * as THREE from 'three'; +import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; + +export interface SceneAPI { + setJointAngles(angles: Record): void; + setJointStates(states: Record): void; + setJointValues(values: Record): void; + onJointClick(cb: (id: JointId) => void): void; + setHighlight(id: JointId | null): void; + dispose(): void; +} + +interface JointHandle { + pivot: any; // Object3D — rotates here, drives the children + halo: any; // TorusGeometry mesh, color = severity + haloMat: any; // MeshBasicMaterial — color mutable + labelEl: HTMLDivElement; + rotationAxis: 'x' | 'y' | 'z'; +} + +// Geometry: revolute axes alternate to mimic a UR-style 6DOF chain +// J1 base yaw (Y), J2 shoulder pitch (Z), J3 elbow pitch (Z), +// J4 wrist pitch (Z), J5 wrist roll (X), J6 tool yaw (Y) +const AXIS: Record = { 1: 'y', 2: 'z', 3: 'z', 4: 'z', 5: 'x', 6: 'y' }; +const SEG_LEN: Record = { 1: 0.4, 2: 1.2, 3: 1.0, 4: 0.5, 5: 0.3, 6: 0.25 }; +const SEG_RAD: Record = { 1: 0.18, 2: 0.13, 3: 0.11, 4: 0.09, 5: 0.08, 6: 0.07 }; + +export async function buildScene(container: HTMLElement): Promise { + // Theme-aware palette — sampled once at scene build. Re-call buildScene + // to repick if the user toggles theme. + const isDark = document.documentElement.dataset.theme === 'dark'; + const palette = isDark + ? { + sceneBg: 0x0e1116, + gridMajor: 0x2a3340, + gridMinor: 0x1a2128, + gridOpacity: 0.55, + baseColor: 0x6a737d, + segColor: 0xb8c0c9, + jointColor: 0x4d8cff, + hemiSky: 0xc8d4ff, + hemiGround: 0x16181f, + keyIntensity: 1.1, + labelBg: 'rgba(20,24,28,0.92)', + labelText: '#f0f4f8', + labelBorder: 'rgba(255,255,255,0.18)', + } + : { + sceneBg: 0xfafbfc, + gridMajor: 0x9aa2ad, + gridMinor: 0xd2d7de, + gridOpacity: 0.5, + baseColor: 0x4a5159, + segColor: 0xd8dce0, + jointColor: 0x2a6df5, + hemiSky: 0xffffff, + hemiGround: 0x303040, + keyIntensity: 0.9, + labelBg: 'rgba(18,22,28,0.85)', + labelText: '#ffffff', + labelBorder: 'rgba(255,255,255,0.18)', + }; + + const scene = new THREE.Scene(); + scene.background = new THREE.Color(palette.sceneBg); + scene.fog = new THREE.Fog(palette.sceneBg, 8, 22); + + const camera = new THREE.PerspectiveCamera(45, 1, 0.05, 100); + camera.position.set(3.5, 2.2, 3.5); + + const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false }); + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + renderer.setSize(container.clientWidth, container.clientHeight); + renderer.domElement.style.display = 'block'; + container.appendChild(renderer.domElement); + + // Lights — three-point so the metal-grey arm has form + scene.add(new THREE.HemisphereLight(palette.hemiSky, palette.hemiGround, 0.7)); + const key = new THREE.DirectionalLight(0xffffff, palette.keyIntensity); key.position.set(4, 6, 3); scene.add(key); + const fill = new THREE.DirectionalLight(0xa0b8ff, 0.4); fill.position.set(-3, 2, -2); scene.add(fill); + + // Floor grid for spatial reference + const grid = new THREE.GridHelper(8, 16, palette.gridMajor, palette.gridMinor); + (grid.material as any).opacity = palette.gridOpacity; + (grid.material as any).transparent = true; + scene.add(grid); + + // Base plate + const base = new THREE.Mesh( + new THREE.CylinderGeometry(0.45, 0.5, 0.12, 32), + new THREE.MeshStandardMaterial({ color: palette.baseColor, metalness: 0.6, roughness: 0.5 }) + ); + base.position.y = 0.06; + scene.add(base); + + const segMat = new THREE.MeshStandardMaterial({ color: palette.segColor, metalness: 0.45, roughness: 0.45 }); + const jointMat = new THREE.MeshStandardMaterial({ color: palette.jointColor, metalness: 0.55, roughness: 0.4 }); + + const joints = {} as Record; + const pickables: any[] = []; + let parent: any = scene; + let yCursor = 0.12; // top of base + + // Label overlay layer — HTML positioned over canvas + const labelLayer = document.createElement('div'); + labelLayer.style.cssText = 'position:absolute;inset:0;pointer-events:none;overflow:hidden;'; + container.style.position = 'relative'; + container.appendChild(labelLayer); + + for (const j of [1, 2, 3, 4, 5, 6] as JointId[]) { + // Pivot lives at the start of this segment; children of pivot rotate with it. + const pivot = new THREE.Object3D(); + pivot.name = `joint_${j}`; + pivot.position.y = yCursor; + parent.add(pivot); + + // Halo (severity ring) around the joint + const haloMat = new THREE.MeshBasicMaterial({ color: SEVERITY_COLORS.ok, transparent: true, opacity: 0.85 }); + const halo = new THREE.Mesh(new THREE.TorusGeometry(SEG_RAD[j] + 0.05, 0.022, 12, 32), haloMat); + // Orient halo around the rotation axis + if (AXIS[j] === 'y') halo.rotation.x = Math.PI / 2; + else if (AXIS[j] === 'x') halo.rotation.y = Math.PI / 2; + halo.userData.jointId = j; + pivot.add(halo); + pickables.push(halo); + + // Joint housing — short cylinder along the rotation axis + const housingLen = SEG_RAD[j] * 1.6; + const housing = new THREE.Mesh( + new THREE.CylinderGeometry(SEG_RAD[j], SEG_RAD[j], housingLen, 24), + jointMat + ); + if (AXIS[j] === 'x') housing.rotation.z = Math.PI / 2; + else if (AXIS[j] === 'z') housing.rotation.x = Math.PI / 2; + housing.userData.jointId = j; + pivot.add(housing); + pickables.push(housing); + + // Segment — connects from this joint to the next, along Y in the pivot frame + const segLen = SEG_LEN[j]; + const seg = new THREE.Mesh( + new THREE.CylinderGeometry(SEG_RAD[j] * 0.85, SEG_RAD[j] * 0.7, segLen, 20), + segMat + ); + seg.position.y = segLen / 2 + housingLen / 2; + pivot.add(seg); + + // HTML label for the joint — larger + higher contrast so the demo reads + // from across the room. + const labelEl = document.createElement('div'); + labelEl.dataset.jointId = String(j); + labelEl.style.cssText = [ + 'position:absolute', 'transform:translate(-50%,-50%)', + 'font:600 14px/1.15 ui-sans-serif,system-ui,sans-serif', + 'letter-spacing:0.01em', + `background:${palette.labelBg}`, + `color:${palette.labelText}`, + 'padding:5px 9px', 'border-radius:8px', 'white-space:nowrap', + 'pointer-events:auto', 'cursor:pointer', + `border:1px solid ${palette.labelBorder}`, + 'box-shadow:0 2px 8px rgba(0,0,0,0.25)', + 'transition:transform 120ms ease', + 'backdrop-filter:blur(4px)', + ].join(';'); + labelEl.textContent = `J${j}`; + labelLayer.appendChild(labelEl); + + joints[j] = { pivot, halo, haloMat, labelEl, rotationAxis: AXIS[j] }; + + // Next iteration starts at the top of the just-added segment + parent = pivot; + yCursor = segLen + housingLen / 2; + } + + // End-effector cap (tool tip) — visual anchor for "the working end" + const tcp = new THREE.Mesh( + new THREE.ConeGeometry(0.05, 0.12, 16), + new THREE.MeshStandardMaterial({ color: 0xff5555, metalness: 0.3, roughness: 0.6 }) + ); + tcp.position.y = yCursor + 0.06; + parent.add(tcp); + + // OrbitControls + const controls = new OrbitControls(camera, renderer.domElement); + controls.target.set(0, 1.0, 0); + controls.enableDamping = true; + controls.dampingFactor = 0.08; + controls.minDistance = 1.5; + controls.maxDistance = 12; + controls.maxPolarAngle = Math.PI / 2 + 0.1; + controls.update(); + + // Click picking via raycaster + const raycaster = new THREE.Raycaster(); + const mouseV = new THREE.Vector2(); + let clickCb: ((id: JointId) => void) | null = null; + + function pickFromEvent(ev: MouseEvent): JointId | null { + const rect = renderer.domElement.getBoundingClientRect(); + mouseV.x = ((ev.clientX - rect.left) / rect.width) * 2 - 1; + mouseV.y = -((ev.clientY - rect.top) / rect.height) * 2 + 1; + raycaster.setFromCamera(mouseV, camera); + const hits = raycaster.intersectObjects(pickables, false); + for (const h of hits) { + const id = (h.object.userData && h.object.userData.jointId) as JointId | undefined; + if (id) return id; + } + return null; + } + + renderer.domElement.addEventListener('click', (ev) => { + const id = pickFromEvent(ev); + if (id && clickCb) clickCb(id); + }); + + // Labels also trigger pick on click + labelLayer.addEventListener('click', (ev) => { + const tgt = ev.target as HTMLElement; + const id = tgt?.dataset?.jointId ? (Number(tgt.dataset.jointId) as JointId) : null; + if (id && clickCb) clickCb(id); + }); + + // Project a world-space Vector3 to a label layer pixel position + const tmpV = new THREE.Vector3(); + function projectLabel(j: JointId) { + const handle = joints[j]; + handle.pivot.updateWorldMatrix(true, false); + handle.pivot.getWorldPosition(tmpV); + tmpV.project(camera); + const w = renderer.domElement.clientWidth, h = renderer.domElement.clientHeight; + const x = (tmpV.x * 0.5 + 0.5) * w; + const y = (-tmpV.y * 0.5 + 0.5) * h; + handle.labelEl.style.left = `${x}px`; + handle.labelEl.style.top = `${y}px`; + handle.labelEl.style.display = tmpV.z > 1 ? 'none' : 'block'; + } + + // Resize observer + const ro = new ResizeObserver(() => { + const w = container.clientWidth, h = container.clientHeight; + if (!w || !h) return; + renderer.setSize(w, h); + camera.aspect = w / h; + camera.updateProjectionMatrix(); + }); + ro.observe(container); + + let highlighted: JointId | null = null; + + // Render loop + let running = true; + function tick() { + if (!running) return; + controls?.update(); + renderer.render(scene, camera); + for (const j of [1, 2, 3, 4, 5, 6] as JointId[]) projectLabel(j); + requestAnimationFrame(tick); + } + requestAnimationFrame(tick); + + return { + setJointAngles(angles) { + for (const j of [1, 2, 3, 4, 5, 6] as JointId[]) { + const a = angles[j]; + if (typeof a !== 'number') continue; + const h = joints[j]; + h.pivot.rotation[h.rotationAxis] = a; + } + }, + setJointStates(states) { + for (const j of [1, 2, 3, 4, 5, 6] as JointId[]) { + const sev = states[j]; + if (!sev) continue; + const col = SEVERITY_COLORS[sev]; + joints[j].haloMat.color.set(col); + // Pulse opacity slightly on crit so it draws the eye + joints[j].haloMat.opacity = sev === 'crit' ? 0.95 : sev === 'warn' ? 0.9 : 0.7; + joints[j].labelEl.style.borderColor = col; + } + }, + setJointValues(values) { + for (const j of [1, 2, 3, 4, 5, 6] as JointId[]) { + const v = values[j]; + if (v === undefined) continue; + joints[j].labelEl.textContent = `J${j} · ${v}`; + } + }, + onJointClick(cb) { clickCb = cb; }, + setHighlight(id) { + highlighted = id; + for (const j of [1, 2, 3, 4, 5, 6] as JointId[]) { + joints[j].labelEl.style.transform = + j === highlighted ? 'translate(-50%,-50%) scale(1.15)' : 'translate(-50%,-50%)'; + joints[j].labelEl.style.zIndex = j === highlighted ? '10' : '1'; + } + }, + dispose() { + running = false; + ro.disconnect(); + renderer.dispose(); + renderer.domElement.remove(); + labelLayer.remove(); + }, + }; +} diff --git a/web-components/src/hardware/schematic2d.ts b/web-components/src/hardware/schematic2d.ts new file mode 100644 index 000000000..262d743aa --- /dev/null +++ b/web-components/src/hardware/schematic2d.ts @@ -0,0 +1,214 @@ +// 2D SVG fallback view of the arm. Same joint IDs (1..6), same setters, so the +// host component can swap views without changing state. Side-view projection; +// joints visualised as labelled circles in a chain, segments as connecting +// trapezoids that rotate with their parent joint. + +import type { JointId, Severity } from './data'; +import { SEVERITY_COLORS } from './data'; + +const SVG_NS = 'http://www.w3.org/2000/svg'; + +// Segment lengths in SVG units (px). Tuned so a fully-extended chain fits +// the 600×600 viewBox with room above the floor line at any pose. +const SEG_LEN: Record = { 1: 30, 2: 130, 3: 115, 4: 55, 5: 30, 6: 28 }; +const JOINT_R: Record = { 1: 18, 2: 14, 3: 13, 4: 11, 5: 9, 6: 8 }; + +interface Handle { + groupEl: SVGGElement; // rotates around the joint center + circleEl: SVGCircleElement; // halo (color = severity) + innerEl: SVGCircleElement; // inner solid joint + labelEl: SVGTextElement; + valueEl: SVGTextElement; +} + +export interface SchematicAPI { + setJointAngles(angles: Record): void; + setJointStates(states: Record): void; + setJointValues(values: Record): void; + onJointClick(cb: (id: JointId) => void): void; + setHighlight(id: JointId | null): void; + dispose(): void; +} + +export function buildSchematic(container: HTMLElement): SchematicAPI { + container.innerHTML = ''; + const svg = document.createElementNS(SVG_NS, 'svg'); + // Fixed viewBox — sized so the arm fits at any pose without scaling. + // (Auto-fit made the whole arm bob around as joints rotated; users found + // it more disorienting than helpful.) + svg.setAttribute('viewBox', '0 0 600 600'); + svg.setAttribute('preserveAspectRatio', 'xMidYMid meet'); + svg.style.cssText = 'width:100%;height:100%;display:block;'; + container.appendChild(svg); + + // Background grid + const defs = document.createElementNS(SVG_NS, 'defs'); + defs.innerHTML = ` + + + + `; + svg.appendChild(defs); + const bg = document.createElementNS(SVG_NS, 'rect'); + bg.setAttribute('width', '600'); bg.setAttribute('height', '600'); + bg.setAttribute('fill', 'url(#hw-grid)'); + svg.appendChild(bg); + + // Floor line + const floor = document.createElementNS(SVG_NS, 'line'); + floor.setAttribute('x1', '40'); floor.setAttribute('y1', '550'); + floor.setAttribute('x2', '560'); floor.setAttribute('y2', '550'); + floor.setAttribute('stroke', 'rgba(80,80,100,0.6)'); floor.setAttribute('stroke-width', '2'); + svg.appendChild(floor); + + // Base plate + const baseRect = document.createElementNS(SVG_NS, 'rect'); + baseRect.setAttribute('x', '260'); baseRect.setAttribute('y', '530'); + baseRect.setAttribute('width', '80'); baseRect.setAttribute('height', '20'); + baseRect.setAttribute('fill', '#4a5159'); + baseRect.setAttribute('rx', '4'); + svg.appendChild(baseRect); + + // Build the joint chain. Each joint group is nested inside its parent. + // Translation moves to "next joint position" (along segment); rotation pivots there. + // Base is at (300, 530). Y grows downward in SVG, but we want the arm to extend "up" + // (toward smaller Y). So we'll negate the Y axis: each segment moves by (0, -SEG_LEN[j]). + const joints = {} as Record; + let clickCb: ((id: JointId) => void) | null = null; + let current: SVGGElement = (() => { + const g = document.createElementNS(SVG_NS, 'g'); + g.setAttribute('transform', 'translate(300, 530)'); + svg.appendChild(g); + return g; + })(); + + for (const j of [1, 2, 3, 4, 5, 6] as JointId[]) { + // Rotation group — rotates the entire downstream chain around the joint center + const rotG = document.createElementNS(SVG_NS, 'g'); + rotG.setAttribute('data-joint', String(j)); + current.appendChild(rotG); + + // Segment (drawn from joint up to next joint position) + const segLen = SEG_LEN[j]; + const segPath = document.createElementNS(SVG_NS, 'path'); + const w0 = JOINT_R[j] * 0.85, w1 = (j < 6 ? JOINT_R[(j + 1) as JointId] : 6) * 0.85; + segPath.setAttribute( + 'd', + `M ${-w0} 0 L ${w0} 0 L ${w1} ${-segLen} L ${-w1} ${-segLen} Z` + ); + segPath.setAttribute('fill', '#d8dce0'); + segPath.setAttribute('stroke', 'rgba(40,40,50,0.3)'); + segPath.setAttribute('stroke-width', '1'); + rotG.appendChild(segPath); + + // Halo (severity color) + const halo = document.createElementNS(SVG_NS, 'circle'); + halo.setAttribute('cx', '0'); halo.setAttribute('cy', '0'); + halo.setAttribute('r', String(JOINT_R[j] + 4)); + halo.setAttribute('fill', 'none'); + halo.setAttribute('stroke', SEVERITY_COLORS.ok); + halo.setAttribute('stroke-width', '3'); + halo.setAttribute('opacity', '0.85'); + rotG.appendChild(halo); + + // Inner joint disc + const inner = document.createElementNS(SVG_NS, 'circle'); + inner.setAttribute('cx', '0'); inner.setAttribute('cy', '0'); + inner.setAttribute('r', String(JOINT_R[j])); + inner.setAttribute('fill', '#2a6df5'); + inner.style.cursor = 'pointer'; + inner.setAttribute('data-joint', String(j)); + rotG.appendChild(inner); + + // Label "J1" + const label = document.createElementNS(SVG_NS, 'text'); + label.setAttribute('x', String(JOINT_R[j] + 14)); + label.setAttribute('y', '4'); + label.setAttribute('font-size', '12'); + label.setAttribute('font-family', 'ui-sans-serif,system-ui,sans-serif'); + label.setAttribute('font-weight', '600'); + label.setAttribute('fill', 'currentColor'); + label.textContent = `J${j}`; + rotG.appendChild(label); + + // Value + const value = document.createElementNS(SVG_NS, 'text'); + value.setAttribute('x', String(JOINT_R[j] + 14)); + value.setAttribute('y', '18'); + value.setAttribute('font-size', '10'); + value.setAttribute('font-family', 'ui-sans-serif,system-ui,sans-serif'); + value.setAttribute('fill', 'rgba(100,100,120,0.9)'); + value.textContent = ''; + rotG.appendChild(value); + + joints[j] = { groupEl: rotG, circleEl: halo, innerEl: inner, labelEl: label, valueEl: value }; + + // Next joint: translate up by segLen, parent becomes a translation group inside rotG. + const trans = document.createElementNS(SVG_NS, 'g'); + trans.setAttribute('transform', `translate(0, ${-segLen})`); + rotG.appendChild(trans); + current = trans; + } + + // End effector marker at the final translated position + const tcp = document.createElementNS(SVG_NS, 'circle'); + tcp.setAttribute('r', '5'); tcp.setAttribute('fill', '#ff5555'); + current.appendChild(tcp); + + svg.addEventListener('click', (ev) => { + const tgt = ev.target as Element; + const id = tgt?.getAttribute('data-joint'); + if (id && clickCb) clickCb(Number(id) as JointId); + }); + + return { + setJointAngles(angles) { + // Side-view projection: only J2/J3/J4 (pitch axes) actually rotate the + // chain in the screen plane. J1 is base yaw (around vertical → not + // visible from the side), J5 is wrist roll (around the link axis), J6 + // is tool yaw — none of these swing the chain in a true side view. + // Show their angle as a small in-place marker rotation on each joint + // so the user still sees them moving, without throwing the chain. + const SIDE_VIEW_JOINTS: JointId[] = [2, 3, 4]; + for (const j of [1, 2, 3, 4, 5, 6] as JointId[]) { + const a = angles[j]; + if (typeof a !== 'number') continue; + const deg = (a * 180) / Math.PI; + if (SIDE_VIEW_JOINTS.includes(j)) { + joints[j].groupEl.setAttribute('transform', `rotate(${deg})`); + } else { + // chain-untouched, but rotate the inner joint disc to hint motion + joints[j].groupEl.setAttribute('transform', 'rotate(0)'); + joints[j].innerEl.setAttribute('transform', `rotate(${deg})`); + } + } + }, + setJointStates(states) { + for (const j of [1, 2, 3, 4, 5, 6] as JointId[]) { + const sev = states[j]; + if (!sev) continue; + const col = SEVERITY_COLORS[sev]; + joints[j].circleEl.setAttribute('stroke', col); + joints[j].circleEl.setAttribute('opacity', sev === 'crit' ? '1' : '0.85'); + } + }, + setJointValues(values) { + for (const j of [1, 2, 3, 4, 5, 6] as JointId[]) { + const v = values[j]; + if (v === undefined) continue; + joints[j].valueEl.textContent = v; + } + }, + onJointClick(cb) { clickCb = cb; }, + setHighlight(id) { + for (const j of [1, 2, 3, 4, 5, 6] as JointId[]) { + const r = JOINT_R[j] + (j === id ? 7 : 4); + joints[j].circleEl.setAttribute('r', String(r)); + joints[j].circleEl.setAttribute('stroke-width', j === id ? '4' : '3'); + } + }, + dispose() { + container.innerHTML = ''; + }, + }; +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 5df064dfe..2f1a2f3a1 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -13,6 +13,7 @@ export * from './log-list'; export * from './widgets'; export * from './charts'; export * from './main'; +export * from './hardware-monitor'; export * from './yaml-editor'; export * from './session-replay'; diff --git a/web-components/src/main.ts b/web-components/src/main.ts index bf11cee77..b0befb54a 100644 --- a/web-components/src/main.ts +++ b/web-components/src/main.ts @@ -1,3 +1,10 @@ +// Named import + visible use of the class — pure side-effect imports were being +// tree-shaken in production builds. +import { HardwareMonitor } from './hardware-monitor'; +if (typeof customElements !== 'undefined' && !customElements.get('hardware-monitor')) { + customElements.define('hardware-monitor', HardwareMonitor); +} + (window as any).htmx.defineExtension('debug', { onEvent: function (name: string, evt: any) { if (console.debug) {