strata-css 1.0.4 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1131 @@
1
+ /*!
2
+ * Strata Chart Component
3
+ *
4
+ * Requires Three.js loaded before this script (window.THREE must exist).
5
+ *
6
+ * Usage:
7
+ * const chart = Strata.Chart.create('#myChart', {
8
+ * type: 'bar' | 'line' | 'pie' | 'scatter',
9
+ * view: '2d' | '3d',
10
+ * data: [{ label, value, category }],
11
+ * colors: ['#hex'],
12
+ * theme: 'auto' | 'light' | 'dark',
13
+ *
14
+ * // Feature flags (all default false)
15
+ * gridView: false, // show scale reference grid
16
+ * showAxisLabels: false, // X-axis category labels
17
+ * showScale: false, // Y-axis numeric scale indicators
18
+ * showGridLabels: false, // labels at each horizontal grid line
19
+ * highlightGridOnInteract: false, // highlight grid lines on hover/click
20
+ *
21
+ * onReady: (chart) => void,
22
+ * onChange: (view) => void,
23
+ * onClick: (point) => void,
24
+ * })
25
+ *
26
+ * chart.toggleView()
27
+ * chart.setView('2d' | '3d')
28
+ * chart.update(newData)
29
+ * chart.addDataPoint(point)
30
+ * chart.removeDataPoint(index)
31
+ * chart.addDataPoints(points)
32
+ * chart.removeDataPoints(indices)
33
+ * chart.updateDataPoint(index, data)
34
+ * chart.destroy()
35
+ *
36
+ * Data attributes set on the container:
37
+ * data-st-chart-view — '2d' | '3d'
38
+ * data-st-chart-type — 'bar' | 'line' | 'pie' | 'scatter'
39
+ * data-st-chart-loading — 'true' | 'false'
40
+ * data-st-chart-animated — 'true' | 'false'
41
+ * data-st-chart-hovered — 'true' | 'false'
42
+ *
43
+ * Events fired on document:
44
+ * st:chart:ready — detail: { chart, view }
45
+ * st:chart:change — detail: { chart, from, to }
46
+ * st:chart:update — detail: { chart }
47
+ * st:chart:click — detail: { label, value, category, index }
48
+ * st:chart:destroy — detail: { chart }
49
+ */
50
+
51
+ // ─── Types ────────────────────────────────────────────────────────────────────
52
+
53
+ type ChartType = 'bar' | 'line' | 'pie' | 'scatter'
54
+ type ChartView = '2d' | '3d'
55
+ type ThemeOpt = 'auto' | 'light' | 'dark'
56
+
57
+ interface RawPoint {
58
+ value: number | null | undefined
59
+ label?: string
60
+ category?: string
61
+ timestamp?: number | string
62
+ meta?: Record<string, unknown>
63
+ }
64
+
65
+ interface NormalizedPoint {
66
+ value: number
67
+ label: string
68
+ category: string
69
+ meta: Record<string, unknown>
70
+ }
71
+
72
+ interface CameraConfig {
73
+ x: number
74
+ y: number
75
+ z: number
76
+ fov: number
77
+ }
78
+
79
+ interface ChartOptions {
80
+ type?: ChartType
81
+ view?: ChartView
82
+ data: RawPoint[]
83
+ colors?: string[]
84
+ theme?: ThemeOpt
85
+ camera3d?: Partial<CameraConfig>
86
+ camera2d?: Partial<CameraConfig>
87
+
88
+ // Feature 1: Grid View System
89
+ gridView?: boolean
90
+
91
+ // Feature 2: Chart Labels & Scales
92
+ showAxisLabels?: boolean
93
+ showScale?: boolean
94
+ showGridLabels?: boolean
95
+
96
+ // Feature 3: Interactive Grid Highlighting
97
+ highlightGridOnInteract?: boolean
98
+
99
+ onReady?: (chart: StrataChart) => void
100
+ onChange?: (view: ChartView) => void
101
+ onClick?: (point: { label: string; value: number; category: string; index: number }) => void
102
+ }
103
+
104
+ interface OrbitControlsInstance {
105
+ enabled: boolean
106
+ enablePan: boolean
107
+ enableZoom: boolean
108
+ enableDamping: boolean
109
+ dampingFactor: number
110
+ object: THREE.Camera
111
+ update(): void
112
+ dispose(): void
113
+ }
114
+
115
+ interface StrataNamespace { Chart: ChartPlugin }
116
+
117
+ interface ChartPlugin {
118
+ create(selector: string | Element, options: ChartOptions): StrataChart | null
119
+ destroyAll(): void
120
+ }
121
+
122
+ interface MeshUserData {
123
+ label?: string
124
+ value?: number
125
+ category?: string
126
+ index?: number
127
+ _depthFrom?: number
128
+ _depthTo?: number
129
+ }
130
+
131
+ interface GridRefs {
132
+ hLines: THREE.Line[]
133
+ vLines: THREE.Line[]
134
+ }
135
+
136
+ interface BuildResult {
137
+ group: THREE.Group
138
+ gridRefs: GridRefs | null
139
+ maxVal: number
140
+ }
141
+
142
+ interface RenderOpts {
143
+ is3D: boolean
144
+ gridView: boolean
145
+ showAxisLabels: boolean
146
+ showScale: boolean
147
+ showGridLabels: boolean
148
+ }
149
+
150
+ // ─── Constants ────────────────────────────────────────────────────────────────
151
+
152
+ const TRANSITION_MS = 600
153
+ // Cinematic 3D angle — elevated, diagonal, dramatic
154
+ const CAMERA_3D = { x: 3.5, y: 6.5, z: 9.5, fov: 42 }
155
+ // Near-orthographic 2D front view — narrow FOV simulates flat projection
156
+ const CAMERA_2D = { x: 0, y: 2, z: 22, fov: 18 }
157
+ const DEFAULT_COLORS = ['#4a90e2', '#e25f4a', '#50c878', '#f5a623', '#9b59b6', '#1abc9c']
158
+ const VALID_TYPES = ['bar', 'line', 'pie', 'scatter'] as ChartType[]
159
+ const MAX_POINTS = 100_000
160
+ const STRIP_HTML = /<[^>]*>/g
161
+ const SCALE_STEPS = 5
162
+ const GRID_COLOR_NORMAL = 0xd4d4d4
163
+ const GRID_COLOR_HIGHLIGHT = '#4a90e2'
164
+
165
+ // ─── Registry ─────────────────────────────────────────────────────────────────
166
+
167
+ const registry = new Map<Element, StrataChart>()
168
+
169
+ // ─── Utilities ────────────────────────────────────────────────────────────────
170
+
171
+ function readCSSVar(name: string, fallback: string): string {
172
+ const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
173
+ return v || fallback
174
+ }
175
+
176
+ function lerp(a: number, b: number, t: number): number { return a + (b - a) * t }
177
+
178
+ // Smooth cubic ease — less aggressive than quadratic, avoids the snap at 1
179
+ function easeInOutCubic(t: number): number {
180
+ return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2
181
+ }
182
+
183
+ function dispatch(name: string, detail: Record<string, unknown>): void {
184
+ document.dispatchEvent(new CustomEvent(name, { detail, bubbles: true }))
185
+ }
186
+
187
+ // ─── Data Pipeline ────────────────────────────────────────────────────────────
188
+
189
+ function validateData(input: unknown): RawPoint[] {
190
+ if (!Array.isArray(input)) throw new Error('[Strata Chart] data must be an array.')
191
+ if (input.length === 0) throw new Error('[Strata Chart] data array is empty.')
192
+ if (input.length > MAX_POINTS) throw new Error(`[Strata Chart] data exceeds ${MAX_POINTS} points.`)
193
+
194
+ return (input as unknown[]).map((item, i): RawPoint => {
195
+ if (typeof item !== 'object' || item === null)
196
+ throw new Error(`[Strata Chart] item at index ${i} must be an object.`)
197
+ const rec = item as Record<string, unknown>
198
+ if (!('value' in rec)) throw new Error(`[Strata Chart] item at index ${i} missing "value".`)
199
+ const raw = rec['value']
200
+ if (raw !== null && raw !== undefined && typeof raw !== 'number')
201
+ throw new Error(`[Strata Chart] item at index ${i}: "value" must be a number, null, or undefined.`)
202
+ return {
203
+ value: raw as number | null | undefined,
204
+ label: typeof rec['label'] === 'string' ? rec['label'].replace(STRIP_HTML, '').slice(0, 256) : undefined,
205
+ category: typeof rec['category'] === 'string' ? rec['category'].replace(STRIP_HTML, '').slice(0, 128) : undefined,
206
+ timestamp: rec['timestamp'] as number | string | undefined,
207
+ meta: (typeof rec['meta'] === 'object' && rec['meta'] !== null) ? rec['meta'] as Record<string, unknown> : undefined,
208
+ }
209
+ })
210
+ }
211
+
212
+ function normalizeData(points: RawPoint[]): NormalizedPoint[] {
213
+ const finite = points.map(p => p.value).filter((v): v is number => typeof v === 'number' && isFinite(v))
214
+ const finiteMax = finite.length > 0 ? Math.max(...finite) : 0
215
+ const finiteMin = finite.length > 0 ? Math.min(...finite) : 0
216
+ const out: NormalizedPoint[] = []
217
+ points.forEach((p, i) => {
218
+ const raw = p.value
219
+ let value: number
220
+ if (raw === null || raw === undefined || (typeof raw === 'number' && isNaN(raw))) {
221
+ value = 0
222
+ } else if (!isFinite(raw)) {
223
+ value = raw === Infinity ? finiteMax : finiteMin
224
+ } else {
225
+ value = raw
226
+ }
227
+ out.push({ value, label: p.label ?? `Point ${i}`, category: p.category ?? 'default', meta: p.meta ?? {} })
228
+ })
229
+ return out
230
+ }
231
+
232
+ function aggregateCategorical(points: NormalizedPoint[]): NormalizedPoint[] {
233
+ const groups = new Map<string, NormalizedPoint[]>()
234
+ for (const p of points) {
235
+ if (!groups.has(p.category)) groups.set(p.category, [])
236
+ groups.get(p.category)!.push(p)
237
+ }
238
+ const out: NormalizedPoint[] = []
239
+ groups.forEach((group, cat) => {
240
+ out.push({ value: group.reduce((s, p) => s + p.value, 0), label: cat, category: cat, meta: {} })
241
+ })
242
+ return out
243
+ }
244
+
245
+ function processData(rawData: unknown, type: ChartType): NormalizedPoint[] {
246
+ const validated = validateData(rawData)
247
+ const normalized = normalizeData(validated)
248
+ return (type === 'bar' || type === 'pie') ? aggregateCategorical(normalized) : normalized
249
+ }
250
+
251
+ // ─── Theme Adapter ────────────────────────────────────────────────────────────
252
+
253
+ function resolveColors(userColors: string[] | undefined, count: number): string[] {
254
+ const palette: string[] = (userColors && userColors.length > 0) ? userColors : [
255
+ readCSSVar('--st-primary', DEFAULT_COLORS[0]),
256
+ readCSSVar('--st-secondary', DEFAULT_COLORS[1]),
257
+ readCSSVar('--st-success', DEFAULT_COLORS[2]),
258
+ readCSSVar('--st-warning', DEFAULT_COLORS[3]),
259
+ readCSSVar('--st-info', DEFAULT_COLORS[4]),
260
+ DEFAULT_COLORS[5],
261
+ ]
262
+ return Array.from({ length: count }, (_, i) => palette[i % palette.length])
263
+ }
264
+
265
+ function resolveTheme(opt: ThemeOpt | undefined): 'light' | 'dark' {
266
+ if (opt === 'dark' || opt === 'light') return opt
267
+ const attr = document.documentElement.getAttribute('data-st-theme')
268
+ return (attr === 'dark' || attr === 'dim') ? 'dark' : 'light'
269
+ }
270
+
271
+ function sceneBgColor(theme: 'light' | 'dark'): string {
272
+ return readCSSVar('--st-bg', theme === 'dark' ? '#16213e' : '#ffffff')
273
+ }
274
+
275
+ // ─── Scene Manager ────────────────────────────────────────────────────────────
276
+
277
+ class SceneManager {
278
+ scene: THREE.Scene
279
+ camera: THREE.PerspectiveCamera
280
+ renderer: THREE.WebGLRenderer
281
+ controls: OrbitControlsInstance | null = null
282
+ private _raf: number | null = null
283
+ private _ro: ResizeObserver | null = null
284
+
285
+ constructor(private container: HTMLElement, theme: 'light' | 'dark', startView: ChartView, camCfg: CameraConfig) {
286
+ const w = container.clientWidth || 400
287
+ const h = container.clientHeight || 300
288
+ const c = camCfg
289
+
290
+ this.scene = new THREE.Scene()
291
+ this.scene.background = new THREE.Color(sceneBgColor(theme))
292
+
293
+ this.camera = new THREE.PerspectiveCamera(c.fov, w / h, 0.1, 1000)
294
+ this.camera.position.set(c.x, c.y, c.z)
295
+ this.camera.lookAt(0, 0, 0)
296
+
297
+ this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false })
298
+ this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
299
+ this.renderer.setSize(w, h)
300
+ this.renderer.shadowMap.enabled = true
301
+
302
+ const canvas = this.renderer.domElement
303
+ canvas.className = 'strata-chart-canvas'
304
+ container.appendChild(canvas)
305
+
306
+ this.scene.add(new THREE.AmbientLight(0xffffff, 0.55))
307
+ const key = new THREE.DirectionalLight(0xffffff, 0.9)
308
+ key.position.set(6, 10, 7)
309
+ key.castShadow = true
310
+ this.scene.add(key)
311
+ const fill = new THREE.DirectionalLight(0xffffff, 0.25)
312
+ fill.position.set(-5, 3, -5)
313
+ this.scene.add(fill)
314
+
315
+ if (THREE.OrbitControls) {
316
+ this.controls = new THREE.OrbitControls(this.camera, canvas)
317
+ this.controls.enablePan = false
318
+ this.controls.enableZoom = true
319
+ this.controls.enableDamping = true
320
+ this.controls.dampingFactor = 0.07
321
+ this.controls.enabled = startView === '3d'
322
+ }
323
+ }
324
+
325
+ startLoop(onFrame: () => void): void {
326
+ const tick = (): void => {
327
+ this._raf = requestAnimationFrame(tick)
328
+ if (this.controls && this.controls.enabled) this.controls.update()
329
+ onFrame()
330
+ this.renderer.render(this.scene, this.camera)
331
+ }
332
+ tick()
333
+ }
334
+
335
+ stopLoop(): void {
336
+ if (this._raf !== null) { cancelAnimationFrame(this._raf); this._raf = null }
337
+ }
338
+
339
+ watchResize(): void {
340
+ if (!window.ResizeObserver) return
341
+ this._ro = new ResizeObserver(() => {
342
+ const w = this.container.clientWidth
343
+ const h = this.container.clientHeight || 300
344
+ this.renderer.setSize(w, h)
345
+ this.camera.aspect = w / h
346
+ this.camera.updateProjectionMatrix()
347
+ })
348
+ this._ro.observe(this.container)
349
+ }
350
+
351
+ dispose(): void {
352
+ this.stopLoop()
353
+ this._ro?.disconnect()
354
+ this.controls?.dispose()
355
+ this.renderer.dispose()
356
+ this.renderer.domElement.parentNode?.removeChild(this.renderer.domElement)
357
+ }
358
+ }
359
+
360
+ // ─── Geometry helpers ─────────────────────────────────────────────────────────
361
+
362
+ function disposeMeshes(scene: THREE.Scene, group: THREE.Group | null): void {
363
+ if (!group) return
364
+ group.traverse(obj => {
365
+ const node = obj as THREE.Mesh
366
+ if (node.geometry) node.geometry.dispose()
367
+ if (node.material) {
368
+ const mats = Array.isArray(node.material) ? node.material : [node.material]
369
+ mats.forEach(m => {
370
+ // dispose CanvasTexture maps attached to SpriteMaterial
371
+ const sm = m as THREE.SpriteMaterial
372
+ if (sm.map) sm.map.dispose()
373
+ m.dispose()
374
+ })
375
+ }
376
+ })
377
+ scene.remove(group)
378
+ }
379
+
380
+ function tagMesh(mesh: THREE.Mesh, p: NormalizedPoint, index: number): void {
381
+ const ud = mesh.userData as MeshUserData
382
+ ud.label = p.label
383
+ ud.value = p.value
384
+ ud.category = p.category
385
+ ud.index = index
386
+ }
387
+
388
+ // ─── Label & Grid helpers ─────────────────────────────────────────────────────
389
+
390
+ function makeTextSprite(text: string, color: string): THREE.Sprite {
391
+ const canvas = document.createElement('canvas')
392
+ canvas.width = 256
393
+ canvas.height = 64
394
+ const ctx = canvas.getContext('2d')!
395
+ ctx.font = '26px sans-serif'
396
+ ctx.fillStyle = color
397
+ ctx.textAlign = 'center'
398
+ ctx.fillText(text, 128, 44)
399
+ const tex = new THREE.CanvasTexture(canvas)
400
+ const mat = new THREE.SpriteMaterial({ map: tex, transparent: true })
401
+ return new THREE.Sprite(mat)
402
+ }
403
+
404
+ // Builds horizontal (scale) and vertical (column) grid lines in the XY plane.
405
+ // Returns the parent group and refs to individual lines for interactive highlighting.
406
+ function buildGridLines(
407
+ points: NormalizedPoint[],
408
+ opts: RenderOpts,
409
+ labelColor: string,
410
+ maxVal: number,
411
+ ): { group: THREE.Group; refs: GridRefs } {
412
+ const group = new THREE.Group()
413
+ const spacing = 1.2
414
+ const n = points.length
415
+ const startX = -(n * spacing / 2) + spacing / 2
416
+ const endX = startX + (n - 1) * spacing
417
+ const margin = spacing / 2
418
+ const hLines: THREE.Line[] = []
419
+ const vLines: THREE.Line[] = []
420
+
421
+ // Horizontal reference lines at each scale step
422
+ for (let s = 0; s < SCALE_STEPS; s++) {
423
+ const y = (s / (SCALE_STEPS - 1)) * 3
424
+ const geo = new THREE.BufferGeometry().setFromPoints([
425
+ new THREE.Vector3(startX - margin, y, 0),
426
+ new THREE.Vector3(endX + margin, y, 0),
427
+ ])
428
+ const mat = new THREE.LineBasicMaterial({ color: GRID_COLOR_NORMAL })
429
+ const line = new THREE.Line(geo, mat)
430
+ group.add(line)
431
+ hLines.push(line)
432
+
433
+ if (opts.showGridLabels) {
434
+ const val = (s / (SCALE_STEPS - 1)) * maxVal
435
+ const label = makeTextSprite(val.toFixed(0), labelColor)
436
+ label.position.set(endX + margin + 0.6, y, 0)
437
+ label.scale.set(1.0, 0.26, 1)
438
+ group.add(label)
439
+ }
440
+ }
441
+
442
+ // Vertical reference lines at each data point
443
+ for (let i = 0; i < n; i++) {
444
+ const x = startX + i * spacing
445
+ const geo = new THREE.BufferGeometry().setFromPoints([
446
+ new THREE.Vector3(x, 0, 0),
447
+ new THREE.Vector3(x, 3, 0),
448
+ ])
449
+ const mat = new THREE.LineBasicMaterial({ color: GRID_COLOR_NORMAL })
450
+ const line = new THREE.Line(geo, mat)
451
+ group.add(line)
452
+ vLines.push(line)
453
+ }
454
+
455
+ return { group, refs: { hLines, vLines } }
456
+ }
457
+
458
+ // ─── Bar Renderer ─────────────────────────────────────────────────────────────
459
+
460
+ function buildBarGroup(points: NormalizedPoint[], colors: string[], opts: RenderOpts): BuildResult {
461
+ const group = new THREE.Group()
462
+ const maxVal = Math.max(...points.map(p => p.value)) || 1
463
+ const spacing = 1.2
464
+ const startX = -(points.length * spacing / 2) + spacing / 2
465
+ const labelColor = readCSSVar('--st-text', '#888888')
466
+ let gridRefs: GridRefs | null = null
467
+
468
+ if (opts.gridView) {
469
+ const { group: gGroup, refs } = buildGridLines(points, opts, labelColor, maxVal)
470
+ group.add(gGroup)
471
+ gridRefs = refs
472
+ }
473
+
474
+ // Floor grid in 3D (existing aesthetic, preserved regardless of gridView)
475
+ if (opts.is3D) {
476
+ const grid = new THREE.GridHelper(points.length * spacing + 1, points.length, 0xaaaaaa, 0xdddddd)
477
+ grid.position.y = -0.01
478
+ group.add(grid)
479
+ }
480
+
481
+ if (opts.showScale) {
482
+ for (let s = 0; s < SCALE_STEPS; s++) {
483
+ const y = (s / (SCALE_STEPS - 1)) * 3
484
+ const val = (s / (SCALE_STEPS - 1)) * maxVal
485
+ const lbl = makeTextSprite(val.toFixed(0), labelColor)
486
+ lbl.position.set(startX - spacing * 0.85, y, 0)
487
+ lbl.scale.set(1.0, 0.26, 1)
488
+ group.add(lbl)
489
+ }
490
+ }
491
+
492
+ points.forEach((p, i) => {
493
+ const h = Math.max((p.value / maxVal) * 3, 0.05)
494
+ const geo = new THREE.BoxGeometry(0.75, h, opts.is3D ? 0.75 : 0.01)
495
+ const mat = new THREE.MeshLambertMaterial({ color: new THREE.Color(colors[i % colors.length]) })
496
+ const mesh = new THREE.Mesh(geo, mat)
497
+ mesh.position.set(startX + i * spacing, h / 2, 0)
498
+ mesh.castShadow = true
499
+ tagMesh(mesh, p, i)
500
+ group.add(mesh)
501
+
502
+ if (opts.showAxisLabels) {
503
+ const lbl = makeTextSprite(p.label, labelColor)
504
+ lbl.position.set(startX + i * spacing, -0.45, 0)
505
+ lbl.scale.set(1.2, 0.3, 1)
506
+ group.add(lbl)
507
+ }
508
+ })
509
+
510
+ return { group, gridRefs, maxVal }
511
+ }
512
+
513
+ // ─── Line Renderer ────────────────────────────────────────────────────────────
514
+
515
+ function buildLineGroup(points: NormalizedPoint[], colors: string[], opts: RenderOpts): BuildResult {
516
+ const group = new THREE.Group()
517
+ const maxVal = Math.max(...points.map(p => p.value)) || 1
518
+ const spacing = 1.2
519
+ const startX = -(points.length * spacing / 2) + spacing / 2
520
+ const labelColor = readCSSVar('--st-text', '#888888')
521
+ let gridRefs: GridRefs | null = null
522
+
523
+ if (opts.gridView) {
524
+ const { group: gGroup, refs } = buildGridLines(points, opts, labelColor, maxVal)
525
+ group.add(gGroup)
526
+ gridRefs = refs
527
+ }
528
+
529
+ if (opts.showScale) {
530
+ for (let s = 0; s < SCALE_STEPS; s++) {
531
+ const y = (s / (SCALE_STEPS - 1)) * 3
532
+ const val = (s / (SCALE_STEPS - 1)) * maxVal
533
+ const lbl = makeTextSprite(val.toFixed(0), labelColor)
534
+ lbl.position.set(startX - spacing * 0.85, y, 0)
535
+ lbl.scale.set(1.0, 0.26, 1)
536
+ group.add(lbl)
537
+ }
538
+ }
539
+
540
+ const verts: THREE.Vector3[] = points.map((p, i) => new THREE.Vector3(
541
+ startX + i * spacing,
542
+ (p.value / maxVal) * 3,
543
+ opts.is3D ? Math.sin(i * 0.6) * 0.4 : 0,
544
+ ))
545
+
546
+ group.add(new THREE.Line(
547
+ new THREE.BufferGeometry().setFromPoints(verts),
548
+ new THREE.LineBasicMaterial({ color: new THREE.Color(colors[0]), linewidth: 2 }),
549
+ ))
550
+
551
+ verts.forEach((v, i) => {
552
+ const mesh = new THREE.Mesh(
553
+ new THREE.SphereGeometry(opts.is3D ? 0.13 : 0.09, 14, 14),
554
+ new THREE.MeshLambertMaterial({ color: new THREE.Color(colors[i % colors.length]) }),
555
+ )
556
+ mesh.position.copy(v)
557
+ tagMesh(mesh, points[i], i)
558
+ group.add(mesh)
559
+
560
+ if (opts.showAxisLabels) {
561
+ const lbl = makeTextSprite(points[i].label, labelColor)
562
+ lbl.position.set(v.x, -0.45, 0)
563
+ lbl.scale.set(1.2, 0.3, 1)
564
+ group.add(lbl)
565
+ }
566
+ })
567
+
568
+ return { group, gridRefs, maxVal }
569
+ }
570
+
571
+ // ─── Pie Renderer ─────────────────────────────────────────────────────────────
572
+ // Group rotation.x is always 0 here — rotation is handled by the transition / init code.
573
+
574
+ function buildPieGroup(points: NormalizedPoint[], colors: string[], opts: RenderOpts): BuildResult {
575
+ const group = new THREE.Group()
576
+ const total = points.reduce((s, p) => s + p.value, 0) || 1
577
+ const height = opts.is3D ? 0.45 : 0.02
578
+ let start = 0
579
+
580
+ points.forEach((p, i) => {
581
+ const arc = (p.value / total) * Math.PI * 2
582
+ const mesh = new THREE.Mesh(
583
+ new THREE.CylinderGeometry(2, 2, height, 80, 1, false, start, arc),
584
+ new THREE.MeshLambertMaterial({ color: new THREE.Color(colors[i % colors.length]) }),
585
+ )
586
+ mesh.castShadow = true
587
+ tagMesh(mesh, p, i)
588
+ group.add(mesh)
589
+ start += arc
590
+ })
591
+
592
+ // gridRefs not applicable to pie
593
+ return { group, gridRefs: null, maxVal: total }
594
+ }
595
+
596
+ // ─── Scatter Renderer ─────────────────────────────────────────────────────────
597
+
598
+ function buildScatterGroup(points: NormalizedPoint[], colors: string[], opts: RenderOpts): BuildResult {
599
+ const group = new THREE.Group()
600
+ const vals = points.map(p => p.value)
601
+ const minVal = Math.min(...vals)
602
+ const maxVal = Math.max(...vals)
603
+ const range = (maxVal - minVal) || 1
604
+ const spacing = 1.2
605
+ const startX = -(points.length * spacing / 2) + spacing / 2
606
+ const labelColor = readCSSVar('--st-text', '#888888')
607
+ let gridRefs: GridRefs | null = null
608
+
609
+ if (opts.gridView) {
610
+ const { group: gGroup, refs } = buildGridLines(points, opts, labelColor, maxVal)
611
+ group.add(gGroup)
612
+ gridRefs = refs
613
+ }
614
+
615
+ if (opts.showScale) {
616
+ for (let s = 0; s < SCALE_STEPS; s++) {
617
+ const y = (s / (SCALE_STEPS - 1)) * 3
618
+ const val = minVal + (s / (SCALE_STEPS - 1)) * (maxVal - minVal)
619
+ const lbl = makeTextSprite(val.toFixed(0), labelColor)
620
+ lbl.position.set(startX - spacing * 0.85, y, 0)
621
+ lbl.scale.set(1.0, 0.26, 1)
622
+ group.add(lbl)
623
+ }
624
+ }
625
+
626
+ // Stable Z positions derived from index to avoid re-roll on update
627
+ points.forEach((p, i) => {
628
+ const norm = (p.value - minVal) / range
629
+ const mesh = new THREE.Mesh(
630
+ new THREE.SphereGeometry(0.09 + norm * 0.17, 14, 14),
631
+ new THREE.MeshLambertMaterial({ color: new THREE.Color(colors[i % colors.length]) }),
632
+ )
633
+ const zPos = opts.is3D ? (((i * 2654435761) % 100) / 50 - 1) : 0
634
+ mesh.position.set(startX + i * spacing, norm * 3, zPos)
635
+ mesh.castShadow = true
636
+ tagMesh(mesh, p, i)
637
+ group.add(mesh)
638
+
639
+ if (opts.showAxisLabels) {
640
+ const lbl = makeTextSprite(p.label, labelColor)
641
+ lbl.position.set(startX + i * spacing, -0.45, 0)
642
+ lbl.scale.set(1.2, 0.3, 1)
643
+ group.add(lbl)
644
+ }
645
+ })
646
+
647
+ return { group, gridRefs, maxVal }
648
+ }
649
+
650
+ // ─── Renderer router ──────────────────────────────────────────────────────────
651
+
652
+ function buildChartGroup(type: ChartType, points: NormalizedPoint[], colors: string[], opts: RenderOpts): BuildResult {
653
+ switch (type) {
654
+ case 'bar': return buildBarGroup(points, colors, opts)
655
+ case 'line': return buildLineGroup(points, colors, opts)
656
+ case 'pie': return buildPieGroup(points, colors, opts)
657
+ case 'scatter': return buildScatterGroup(points, colors, opts)
658
+ }
659
+ }
660
+
661
+ // ─── View Transition ──────────────────────────────────────────────────────────
662
+ // Uses a single PerspectiveCamera throughout. Going to 2D animates to a very
663
+ // narrow FOV + front position — visually indistinguishable from orthographic.
664
+ // No camera swap means the transition is always continuous and smooth.
665
+
666
+ class ChartViewTransition {
667
+ private _raf: number | null = null
668
+
669
+ constructor(private sm: SceneManager, private opts: ChartOptions) {}
670
+
671
+ cancelTransition(): void {
672
+ if (this._raf !== null) { cancelAnimationFrame(this._raf); this._raf = null }
673
+ }
674
+
675
+ run(
676
+ toView: ChartView,
677
+ group: THREE.Group,
678
+ container: HTMLElement,
679
+ fromRotX: number,
680
+ toRotX: number,
681
+ skipDepth: boolean,
682
+ onComplete: () => void,
683
+ ): void {
684
+ // Cancel any in-progress transition so rapid toggles always reach the correct end state
685
+ this.cancelTransition()
686
+
687
+ container.setAttribute('data-st-chart-animated', 'true')
688
+ if (this.sm.controls) this.sm.controls.enabled = false
689
+
690
+ const is3D = toView === '3d'
691
+ const started = performance.now()
692
+ const fromPos = this.sm.camera.position.clone()
693
+ const fromFov = this.sm.camera.fov
694
+ const base = is3D ? CAMERA_3D : CAMERA_2D
695
+ const over = is3D ? (this.opts.camera3d ?? {}) : (this.opts.camera2d ?? {})
696
+ const toC = { ...base, ...over }
697
+ const toPos = new THREE.Vector3(toC.x, toC.y, toC.z)
698
+ const toFov = toC.fov
699
+
700
+ group.rotation.x = fromRotX
701
+
702
+ if (!skipDepth) {
703
+ group.traverse(obj => {
704
+ const mesh = obj as THREE.Mesh
705
+ const ud = mesh.userData as MeshUserData
706
+ if (!mesh.isMesh || ud.index === undefined) return
707
+ ud._depthFrom = mesh.scale.z
708
+ ud._depthTo = is3D ? 1 : 0.01
709
+ })
710
+ }
711
+
712
+ const tick = (): void => {
713
+ const t = Math.min((performance.now() - started) / TRANSITION_MS, 1)
714
+ const et = easeInOutCubic(t)
715
+
716
+ this.sm.camera.position.lerpVectors(fromPos, toPos, et)
717
+ this.sm.camera.fov = lerp(fromFov, toFov, et)
718
+ this.sm.camera.lookAt(0, 0, 0)
719
+ this.sm.camera.updateProjectionMatrix()
720
+
721
+ group.rotation.x = lerp(fromRotX, toRotX, et)
722
+
723
+ if (!skipDepth) {
724
+ group.traverse(obj => {
725
+ const mesh = obj as THREE.Mesh
726
+ const ud = mesh.userData as MeshUserData
727
+ if (!mesh.isMesh || ud.index === undefined || ud._depthFrom === undefined || ud._depthTo === undefined) return
728
+ mesh.scale.z = lerp(ud._depthFrom, ud._depthTo, et)
729
+ })
730
+ }
731
+
732
+ if (t < 1) {
733
+ this._raf = requestAnimationFrame(tick)
734
+ } else {
735
+ this._raf = null
736
+ if (!skipDepth) {
737
+ group.traverse(obj => {
738
+ const ud = (obj as THREE.Mesh).userData as MeshUserData
739
+ delete ud._depthFrom
740
+ delete ud._depthTo
741
+ })
742
+ }
743
+ container.setAttribute('data-st-chart-animated', 'false')
744
+ if (is3D && this.sm.controls) this.sm.controls.enabled = true
745
+ onComplete()
746
+ }
747
+ }
748
+
749
+ this._raf = requestAnimationFrame(tick)
750
+ }
751
+ }
752
+
753
+ // ─── Interaction Manager ──────────────────────────────────────────────────────
754
+
755
+ class InteractionManager {
756
+ private raycaster = new THREE.Raycaster()
757
+ private mouse = new THREE.Vector2(-9999, -9999)
758
+ private hovered: THREE.Mesh | null = null
759
+ private tooltip: HTMLElement
760
+ private _group: THREE.Group | null = null
761
+ private _canvas: HTMLCanvasElement
762
+
763
+ // Called when hovered mesh changes; null means unhovered
764
+ onHoverChange: ((index: number | null, value: number | null) => void) | null = null
765
+
766
+ constructor(private sm: SceneManager, private container: HTMLElement) {
767
+ this._canvas = sm.renderer.domElement
768
+
769
+ this.tooltip = document.createElement('div')
770
+ this.tooltip.className = 'strata-chart-tooltip'
771
+ this.tooltip.setAttribute('data-st-chart-tooltip', 'false')
772
+ container.appendChild(this.tooltip)
773
+
774
+ this._canvas.addEventListener('mousemove', this._onMove.bind(this))
775
+ this._canvas.addEventListener('mouseleave', this._onLeave.bind(this))
776
+ this._canvas.addEventListener('click', this._onClick.bind(this))
777
+ }
778
+
779
+ setGroup(group: THREE.Group | null): void {
780
+ if (this.hovered) { this._unhover(this.hovered); this.hovered = null }
781
+ this._group = group
782
+ this.tooltip.setAttribute('data-st-chart-tooltip', 'false')
783
+ }
784
+
785
+ update(): void {
786
+ if (!this._group) return
787
+
788
+ const meshes: THREE.Object3D[] = []
789
+ this._group.traverse(obj => {
790
+ const mesh = obj as THREE.Mesh
791
+ if (mesh.isMesh && (mesh.userData as MeshUserData).index !== undefined) meshes.push(mesh)
792
+ })
793
+
794
+ this.raycaster.setFromCamera(this.mouse, this.sm.camera)
795
+ const hits = this.raycaster.intersectObjects(meshes, false)
796
+
797
+ if (hits.length > 0) {
798
+ const hit = hits[0].object as THREE.Mesh
799
+ if (hit !== this.hovered) {
800
+ if (this.hovered) this._unhover(this.hovered)
801
+ this._hover(hit)
802
+ this.hovered = hit
803
+ }
804
+ this._positionTooltip(hits[0].point)
805
+ } else if (this.hovered) {
806
+ this._unhover(this.hovered)
807
+ this.hovered = null
808
+ this.tooltip.setAttribute('data-st-chart-tooltip', 'false')
809
+ }
810
+ }
811
+
812
+ dispose(): void {
813
+ this._canvas.removeEventListener('mousemove', this._onMove.bind(this))
814
+ this._canvas.removeEventListener('mouseleave', this._onLeave.bind(this))
815
+ this._canvas.removeEventListener('click', this._onClick.bind(this))
816
+ this.tooltip.parentNode?.removeChild(this.tooltip)
817
+ }
818
+
819
+ private _onMove(e: MouseEvent): void {
820
+ const rect = this._canvas.getBoundingClientRect()
821
+ this.mouse.set(
822
+ ((e.clientX - rect.left) / rect.width) * 2 - 1,
823
+ ((e.clientY - rect.top) / rect.height) * -2 + 1,
824
+ )
825
+ }
826
+
827
+ private _onLeave(): void {
828
+ this.mouse.set(-9999, -9999)
829
+ if (this.hovered) { this._unhover(this.hovered); this.hovered = null }
830
+ this.tooltip.setAttribute('data-st-chart-tooltip', 'false')
831
+ }
832
+
833
+ private _onClick(): void {
834
+ if (!this.hovered) return
835
+ const ud = this.hovered.userData as MeshUserData
836
+ dispatch('st:chart:click', { label: ud.label ?? '', value: ud.value ?? 0, category: ud.category ?? '', index: ud.index ?? 0 })
837
+ }
838
+
839
+ private _hover(mesh: THREE.Mesh): void {
840
+ const mat = mesh.material as THREE.MeshLambertMaterial
841
+ mat.emissive.set(0x555555)
842
+ const ud = mesh.userData as MeshUserData
843
+ this.tooltip.innerHTML = `<span class="strata-chart-tooltip-label">${ud.label ?? ''}</span><span class="strata-chart-tooltip-value">${ud.value ?? 0}</span>`
844
+ this.tooltip.setAttribute('data-st-chart-tooltip', 'true')
845
+ this.container.setAttribute('data-st-chart-hovered', 'true')
846
+ this.onHoverChange?.(ud.index ?? null, ud.value ?? null)
847
+ }
848
+
849
+ private _unhover(mesh: THREE.Mesh): void {
850
+ const mat = mesh.material as THREE.MeshLambertMaterial
851
+ mat.emissive.set(0x000000)
852
+ this.container.setAttribute('data-st-chart-hovered', 'false')
853
+ this.onHoverChange?.(null, null)
854
+ }
855
+
856
+ private _positionTooltip(worldPoint: THREE.Vector3): void {
857
+ const projected = worldPoint.clone().project(this.sm.camera)
858
+ const w = this._canvas.clientWidth
859
+ const h = this._canvas.clientHeight
860
+ const x = Math.min((projected.x * 0.5 + 0.5) * w + 14, w - 130)
861
+ const y = Math.max((-projected.y * 0.5 + 0.5) * h - 46, 4)
862
+ this.tooltip.style.left = x + 'px'
863
+ this.tooltip.style.top = y + 'px'
864
+ }
865
+ }
866
+
867
+ // ─── StrataChart (public instance) ───────────────────────────────────────────
868
+
869
+ class StrataChart {
870
+ private _sm: SceneManager
871
+ private _vt: ChartViewTransition
872
+ private _interaction: InteractionManager
873
+ private _group: THREE.Group | null = null
874
+ private _gridRefs: GridRefs | null = null
875
+ private _maxVal: number = 1
876
+ private _destroyed = false
877
+
878
+ constructor(
879
+ private readonly container: HTMLElement,
880
+ private _opts: ChartOptions,
881
+ ) {
882
+ const theme = resolveTheme(_opts.theme)
883
+ const type = _opts.type ?? 'bar'
884
+ const view = _opts.view === '2d' ? '2d' : '3d' as ChartView
885
+ const is3D = view === '3d'
886
+
887
+ container.classList.add('strata-chart')
888
+ container.setAttribute('data-st-chart-type', type)
889
+ container.setAttribute('data-st-chart-view', view)
890
+ container.setAttribute('data-st-chart-loading', 'true')
891
+ container.setAttribute('data-st-chart-animated', 'false')
892
+ container.setAttribute('data-st-chart-hovered', 'false')
893
+
894
+ if (!container.style.height) container.style.height = '300px'
895
+
896
+ const initCam = { ...(_opts.view === '2d' ? CAMERA_2D : CAMERA_3D), ...(_opts.view === '2d' ? (_opts.camera2d ?? {}) : (_opts.camera3d ?? {})) }
897
+ this._sm = new SceneManager(container, theme, view, initCam)
898
+ this._vt = new ChartViewTransition(this._sm, _opts)
899
+ this._interaction = new InteractionManager(this._sm, container)
900
+
901
+ this._interaction.onHoverChange = (index, value) => this._applyGridHighlight(index, value)
902
+
903
+ const points = processData(_opts.data, type)
904
+ const colors = resolveColors(_opts.colors, points.length)
905
+ const result = buildChartGroup(type, points, colors, this._renderOpts(is3D))
906
+ this._group = result.group
907
+ this._gridRefs = result.gridRefs
908
+ this._maxVal = result.maxVal
909
+
910
+ if (type === 'pie' && !is3D) this._group.rotation.x = Math.PI / 2
911
+ this._sm.scene.add(this._group)
912
+ this._interaction.setGroup(this._group)
913
+
914
+ this._sm.startLoop(() => this._interaction.update())
915
+ this._sm.watchResize()
916
+
917
+ container.setAttribute('data-st-chart-loading', 'false')
918
+ dispatch('st:chart:ready', { chart: this as unknown as Record<string, unknown>, view })
919
+ _opts.onReady?.(this)
920
+ }
921
+
922
+ setView(view: ChartView): void {
923
+ if (this._destroyed) return
924
+ const current = this.container.getAttribute('data-st-chart-view') as ChartView
925
+ if (view === current) return
926
+
927
+ const type = this._opts.type ?? 'bar'
928
+ const is3D = view === '3d'
929
+ const points = processData(this._opts.data, type)
930
+ const colors = resolveColors(this._opts.colors, points.length)
931
+ const isPie = type === 'pie'
932
+
933
+ disposeMeshes(this._sm.scene, this._group)
934
+ const result = buildChartGroup(type, points, colors, this._renderOpts(is3D))
935
+ this._group = result.group
936
+ this._gridRefs = result.gridRefs
937
+ this._maxVal = result.maxVal
938
+
939
+ this._sm.scene.add(this._group)
940
+ this._interaction.setGroup(this._group)
941
+ this.container.setAttribute('data-st-chart-view', view)
942
+
943
+ const fromRotX = isPie ? (current === '2d' ? Math.PI / 2 : 0) : 0
944
+ const toRotX = isPie ? (is3D ? 0 : Math.PI / 2) : 0
945
+
946
+ this._vt.run(view, this._group, this.container, fromRotX, toRotX, isPie, () => {
947
+ dispatch('st:chart:change', { chart: this as unknown as Record<string, unknown>, from: current, to: view })
948
+ this._opts.onChange?.(view)
949
+ })
950
+ }
951
+
952
+ toggleView(): void {
953
+ if (this._destroyed) return
954
+ const current = this.container.getAttribute('data-st-chart-view') as ChartView
955
+ this.setView(current === '3d' ? '2d' : '3d')
956
+ }
957
+
958
+ update(newData: RawPoint[]): void {
959
+ if (this._destroyed) return
960
+ this._opts.data = newData
961
+ this._rebuild()
962
+ dispatch('st:chart:update', { chart: this as unknown as Record<string, unknown> })
963
+ }
964
+
965
+ // ─── Feature 4: Dynamic Data Entry API ───────────────────────────────────────
966
+
967
+ addDataPoint(point: RawPoint): void {
968
+ if (this._destroyed) return
969
+ this._opts.data = [...this._opts.data, point]
970
+ this._rebuild()
971
+ dispatch('st:chart:update', { chart: this as unknown as Record<string, unknown> })
972
+ }
973
+
974
+ removeDataPoint(index: number): void {
975
+ if (this._destroyed) return
976
+ if (index < 0 || index >= this._opts.data.length) return
977
+ this._opts.data = this._opts.data.filter((_, i) => i !== index)
978
+ if (this._opts.data.length === 0) return
979
+ this._rebuild()
980
+ dispatch('st:chart:update', { chart: this as unknown as Record<string, unknown> })
981
+ }
982
+
983
+ addDataPoints(points: RawPoint[]): void {
984
+ if (this._destroyed || points.length === 0) return
985
+ this._opts.data = [...this._opts.data, ...points]
986
+ this._rebuild()
987
+ dispatch('st:chart:update', { chart: this as unknown as Record<string, unknown> })
988
+ }
989
+
990
+ removeDataPoints(indices: number[]): void {
991
+ if (this._destroyed || indices.length === 0) return
992
+ const set = new Set(indices)
993
+ const next = this._opts.data.filter((_, i) => !set.has(i))
994
+ if (next.length === 0) return
995
+ this._opts.data = next
996
+ this._rebuild()
997
+ dispatch('st:chart:update', { chart: this as unknown as Record<string, unknown> })
998
+ }
999
+
1000
+ updateDataPoint(index: number, data: Partial<RawPoint>): void {
1001
+ if (this._destroyed) return
1002
+ if (index < 0 || index >= this._opts.data.length) return
1003
+ this._opts.data = this._opts.data.map((p, i) => i === index ? { ...p, ...data } : p)
1004
+ this._rebuild()
1005
+ dispatch('st:chart:update', { chart: this as unknown as Record<string, unknown> })
1006
+ }
1007
+
1008
+ destroy(): void {
1009
+ if (this._destroyed) return
1010
+ this._destroyed = true
1011
+
1012
+ disposeMeshes(this._sm.scene, this._group)
1013
+ this._interaction.dispose()
1014
+ this._sm.dispose()
1015
+
1016
+ this.container.classList.remove('strata-chart')
1017
+ this.container.removeAttribute('data-st-chart-type')
1018
+ this.container.removeAttribute('data-st-chart-view')
1019
+ this.container.removeAttribute('data-st-chart-loading')
1020
+ this.container.removeAttribute('data-st-chart-animated')
1021
+ this.container.removeAttribute('data-st-chart-hovered')
1022
+
1023
+ registry.delete(this.container)
1024
+ dispatch('st:chart:destroy', { chart: this as unknown as Record<string, unknown> })
1025
+ }
1026
+
1027
+ // ─── Private helpers ──────────────────────────────────────────────────────────
1028
+
1029
+ private _renderOpts(is3D: boolean): RenderOpts {
1030
+ return {
1031
+ is3D,
1032
+ gridView: this._opts.gridView ?? false,
1033
+ showAxisLabels: this._opts.showAxisLabels ?? false,
1034
+ showScale: this._opts.showScale ?? false,
1035
+ showGridLabels: this._opts.showGridLabels ?? false,
1036
+ }
1037
+ }
1038
+
1039
+ private _rebuild(): void {
1040
+ const type = this._opts.type ?? 'bar'
1041
+ const view = this.container.getAttribute('data-st-chart-view') as ChartView
1042
+ const is3D = view === '3d'
1043
+
1044
+ this.container.setAttribute('data-st-chart-loading', 'true')
1045
+
1046
+ const points = processData(this._opts.data, type)
1047
+ const colors = resolveColors(this._opts.colors, points.length)
1048
+ disposeMeshes(this._sm.scene, this._group)
1049
+ const result = buildChartGroup(type, points, colors, this._renderOpts(is3D))
1050
+ this._group = result.group
1051
+ this._gridRefs = result.gridRefs
1052
+ this._maxVal = result.maxVal
1053
+
1054
+ if (type === 'pie' && !is3D) this._group.rotation.x = Math.PI / 2
1055
+ this._sm.scene.add(this._group)
1056
+ this._interaction.setGroup(this._group)
1057
+
1058
+ this.container.setAttribute('data-st-chart-loading', 'false')
1059
+ }
1060
+
1061
+ // Feature 3: highlight/restore grid lines on hover
1062
+ private _applyGridHighlight(index: number | null, value: number | null): void {
1063
+ if (!this._gridRefs || !(this._opts.highlightGridOnInteract ?? false)) return
1064
+
1065
+ const { hLines, vLines } = this._gridRefs
1066
+
1067
+ if (index === null || value === null) {
1068
+ // Restore all lines to normal color
1069
+ hLines.forEach(l => (l.material as THREE.LineBasicMaterial).color.set(GRID_COLOR_NORMAL))
1070
+ vLines.forEach(l => (l.material as THREE.LineBasicMaterial).color.set(GRID_COLOR_NORMAL))
1071
+ return
1072
+ }
1073
+
1074
+ // Highlight the vertical line for this data point
1075
+ vLines.forEach((l, i) => {
1076
+ (l.material as THREE.LineBasicMaterial).color.set(
1077
+ i === index ? GRID_COLOR_HIGHLIGHT : GRID_COLOR_NORMAL,
1078
+ )
1079
+ })
1080
+
1081
+ // Highlight the horizontal line closest to this data point's rendered Y position
1082
+ const renderedY = (value / this._maxVal) * 3
1083
+ let closestIdx = 0
1084
+ let closestDist = Infinity
1085
+ hLines.forEach((_, s) => {
1086
+ const lineY = (s / (SCALE_STEPS - 1)) * 3
1087
+ const dist = Math.abs(lineY - renderedY)
1088
+ if (dist < closestDist) { closestDist = dist; closestIdx = s }
1089
+ })
1090
+ hLines.forEach((l, s) => {
1091
+ (l.material as THREE.LineBasicMaterial).color.set(
1092
+ s === closestIdx ? GRID_COLOR_HIGHLIGHT : GRID_COLOR_NORMAL,
1093
+ )
1094
+ })
1095
+ }
1096
+ }
1097
+
1098
+ // ─── Bootstrap IIFE ───────────────────────────────────────────────────────────
1099
+
1100
+ ;(function (win: Window & typeof globalThis & { Strata?: Partial<StrataNamespace>; StrataChart?: Partial<StrataNamespace['Chart']> }) {
1101
+ if (!(win as unknown as Record<string, unknown>)['THREE']) {
1102
+ console.error('[Strata Chart] Three.js (window.THREE) is required. Load it before this script.')
1103
+ return
1104
+ }
1105
+
1106
+ const api = {
1107
+ create(selector: string | Element, options: ChartOptions): StrataChart | null {
1108
+ const container = typeof selector === 'string' ? document.querySelector(selector) : selector as Element
1109
+ if (!container) { console.error(`[Strata Chart] Element not found: ${String(selector)}`); return null }
1110
+ if (registry.has(container)) {
1111
+ console.warn('[Strata Chart] Chart already mounted here. Call .destroy() first.')
1112
+ return registry.get(container)!
1113
+ }
1114
+ if (!Array.isArray(options?.data)) { console.error('[Strata Chart] options.data must be an array.'); return null }
1115
+ if (options.type && !VALID_TYPES.includes(options.type)) {
1116
+ console.error(`[Strata Chart] Invalid type "${options.type}". Use: ${VALID_TYPES.join(', ')}`)
1117
+ return null
1118
+ }
1119
+ const instance = new StrataChart(container as HTMLElement, options)
1120
+ registry.set(container, instance)
1121
+ return instance
1122
+ },
1123
+ destroyAll() { registry.forEach(inst => inst.destroy()) },
1124
+ }
1125
+
1126
+ if (win.Strata) {
1127
+ win.Strata.Chart = api
1128
+ } else {
1129
+ win.StrataChart = api
1130
+ }
1131
+ }(window))