strata-css 1.0.3 → 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.
- package/README.md +112 -9
- package/bin/strata.js +342 -91
- package/package.json +22 -12
- package/src/components/modules/chart/src/chart.ts +1131 -0
- package/src/components/modules/chart/src/three-global.d.ts +188 -0
- package/src/components/modules/chart/tsconfig.json +19 -0
- package/src/components/modules/init.js +7 -0
- package/src/components/strata.manifest.js +19 -15
- package/src/layers/base.js +573 -571
- package/src/registry/registry.js +2951 -2897
- package/src/components/modules/modal.js +0 -123
- package/src/components/modules/skeleton.js +0 -334
|
@@ -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))
|