diff --git a/src/components/LanguageSelector.astro b/src/components/LanguageSelector.astro
index a489c88..30fb4a2 100644
--- a/src/components/LanguageSelector.astro
+++ b/src/components/LanguageSelector.astro
@@ -61,39 +61,45 @@ const dropdownClass = isUp
diff --git a/src/components/Sidebar.astro b/src/components/Sidebar.astro
index 293e771..2dbaba2 100644
--- a/src/components/Sidebar.astro
+++ b/src/components/Sidebar.astro
@@ -136,27 +136,33 @@ import logoLight from '../assets/logo-light.png'
diff --git a/src/components/TopBar.astro b/src/components/TopBar.astro
index ff095d0..9690297 100644
--- a/src/components/TopBar.astro
+++ b/src/components/TopBar.astro
@@ -84,64 +84,70 @@ const mappedArticles = rawArticles.map((a) => ({
diff --git a/src/components/content/AreaChart.astro b/src/components/content/AreaChart.astro
index 009dade..eaf5d86 100644
--- a/src/components/content/AreaChart.astro
+++ b/src/components/content/AreaChart.astro
@@ -63,299 +63,312 @@ const chartColors = JSON.stringify(colors)
diff --git a/src/components/content/BarChart.astro b/src/components/content/BarChart.astro
index 9c7cadb..5657260 100644
--- a/src/components/content/BarChart.astro
+++ b/src/components/content/BarChart.astro
@@ -54,207 +54,216 @@ const chartColors = JSON.stringify(colors)
diff --git a/src/components/content/CsvViewer.astro b/src/components/content/CsvViewer.astro
index 8d9011a..ababdc3 100644
--- a/src/components/content/CsvViewer.astro
+++ b/src/components/content/CsvViewer.astro
@@ -457,6 +457,11 @@ const viewerDelimiter = delimiter
}
}
- const wrappers = document.querySelectorAll('.csv-viewer-wrapper')
- wrappers.forEach((wrapper) => new CsvViewer(wrapper as HTMLElement))
+ function initCsvViewers() {
+ const wrappers = document.querySelectorAll('.csv-viewer-wrapper')
+ wrappers.forEach((wrapper) => new CsvViewer(wrapper as HTMLElement))
+ }
+
+ import { onHydration } from '../../utils/hydration'
+ onHydration(initCsvViewers)
diff --git a/src/components/content/JsonViewer.astro b/src/components/content/JsonViewer.astro
index c948ade..954d346 100644
--- a/src/components/content/JsonViewer.astro
+++ b/src/components/content/JsonViewer.astro
@@ -427,6 +427,11 @@ const viewerExpanded = expanded
}
}
- const wrappers = document.querySelectorAll('.json-viewer-wrapper')
- wrappers.forEach((wrapper) => new JsonViewer(wrapper as HTMLElement))
+ function initJsonViewers() {
+ const wrappers = document.querySelectorAll('.json-viewer-wrapper')
+ wrappers.forEach((wrapper) => new JsonViewer(wrapper as HTMLElement))
+ }
+
+ import { onHydration } from '../../utils/hydration'
+ onHydration(initJsonViewers)
diff --git a/src/components/content/LineChart.astro b/src/components/content/LineChart.astro
index ecbbb38..4e1ccc3 100644
--- a/src/components/content/LineChart.astro
+++ b/src/components/content/LineChart.astro
@@ -92,201 +92,268 @@ const chartColors = JSON.stringify(colors)
diff --git a/src/components/content/PieChart.astro b/src/components/content/PieChart.astro
index 0a89757..5dfc3df 100644
--- a/src/components/content/PieChart.astro
+++ b/src/components/content/PieChart.astro
@@ -51,282 +51,292 @@ const chartColors = JSON.stringify(colors)
diff --git a/src/components/content/ScatterChart.astro b/src/components/content/ScatterChart.astro
index 9d8ed9c..214e8f6 100644
--- a/src/components/content/ScatterChart.astro
+++ b/src/components/content/ScatterChart.astro
@@ -98,388 +98,397 @@ const chartColors = JSON.stringify(colors)
diff --git a/src/components/content/ScatterChart3D.astro b/src/components/content/ScatterChart3D.astro
index 5a98f9d..22d0f6d 100644
--- a/src/components/content/ScatterChart3D.astro
+++ b/src/components/content/ScatterChart3D.astro
@@ -99,438 +99,447 @@ const chartColors = JSON.stringify(colors)
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
import * as d3 from 'd3'
import { getThemeColors, getColor, createTooltip, showTooltip, hideTooltip } from '../../utils/chartUtils'
+ import { onHydration } from '../../utils/hydration'
const CUBE = 1.8
- const wrappers = document.querySelectorAll('.chart-wrapper[data-chart-id^="scatter3d-chart"]')
-
- wrappers.forEach((wrapper) => {
- const container = wrapper.querySelector('div') as HTMLElement
- if (!container) return
-
- const rawData = (wrapper as HTMLElement).dataset.chartData
- const chartData: Array<{ x: number; y: number; z: number; color?: string; label?: string }> = rawData
- ? JSON.parse(rawData).map((d: { x: number; y: number; z?: number; color?: string; label?: string }) => ({
- ...d,
- z: d.z ?? 0,
- }))
- : []
-
- if (!chartData.length) return
-
- const colors: string[] = JSON.parse((wrapper as HTMLElement).dataset.chartColors || '[]')
- const animated = (wrapper as HTMLElement).dataset.chartAnimated !== 'false'
- const pointSize = parseFloat((wrapper as HTMLElement).dataset.chartPointSize || '8')
- const pointOpacity = parseFloat((wrapper as HTMLElement).dataset.chartPointOpacity || '1')
- const showGrid = (wrapper as HTMLElement).dataset.chartShowGrid !== 'false'
- const xAxisConfig = JSON.parse((wrapper as HTMLElement).dataset.chartXAxis || '{}')
- const yAxisConfig = JSON.parse((wrapper as HTMLElement).dataset.chartYAxis || '{}')
- const zAxisConfig = JSON.parse((wrapper as HTMLElement).dataset.chartZAxis || '{}')
- const chartHeight = parseInt((wrapper as HTMLElement).dataset.chartHeight || '400')
- const chartTitle = (wrapper as HTMLElement).dataset.chartTitle || ''
-
- const xMin = xAxisConfig.min ?? d3.min(chartData, (d) => d.x) ?? 0
- const xMax = xAxisConfig.max ?? d3.max(chartData, (d) => d.x) ?? 1
- const yMin = yAxisConfig.min ?? d3.min(chartData, (d) => d.y) ?? 0
- const yMax = yAxisConfig.max ?? d3.max(chartData, (d) => d.y) ?? 1
- const zMin = zAxisConfig.min ?? d3.min(chartData, (d) => d.z) ?? 0
- const zMax = zAxisConfig.max ?? d3.max(chartData, (d) => d.z) ?? 1
-
- const scaleX = d3.scaleLinear().domain([xMin, xMax]).range([0, CUBE])
- const scaleY = d3.scaleLinear().domain([yMin, yMax]).range([0, CUBE])
- const scaleZ = d3.scaleLinear().domain([zMin, zMax]).range([0, CUBE])
-
- let scene: THREE.Scene
- let camera: THREE.PerspectiveCamera
- let renderer: THREE.WebGLRenderer
- let controls: OrbitControls
- let pointsGroup: THREE.Group
- let raycaster: THREE.Raycaster
- let mouse: THREE.Vector2
- let hoveredMesh: THREE.Mesh | null = null
-
- const DEFAULT_EYE = new THREE.Vector3(CUBE * 0.5, CUBE * 0.6, CUBE * 2.5)
- const CENTER = new THREE.Vector3(CUBE / 2, CUBE / 2, CUBE / 2)
-
- function makeLabel(
- text: string,
- position: THREE.Vector3,
- colorHex: number,
- scaleW = 0.28,
- scaleH = 0.1,
- fontPx = 28,
- ) {
- const canvas = document.createElement('canvas')
- canvas.width = 256
- canvas.height = 64
- const ctx = canvas.getContext('2d')!
- ctx.clearRect(0, 0, 256, 64)
- ctx.fillStyle = `#${colorHex.toString(16).padStart(6, '0')}`
- ctx.font = `${fontPx}px monospace`
- ctx.textAlign = 'center'
- ctx.textBaseline = 'middle'
- ctx.fillText(text, 128, 32)
- const texture = new THREE.CanvasTexture(canvas)
- texture.needsUpdate = true
- const mat = new THREE.SpriteMaterial({ map: texture, transparent: true, depthTest: false })
- const sprite = new THREE.Sprite(mat)
- sprite.position.copy(position)
- sprite.scale.set(scaleW, scaleH, 1)
- return sprite
- }
-
- function makeFaceGrid(
- origin: THREE.Vector3,
- uDir: THREE.Vector3,
- vDir: THREE.Vector3,
- divisions: number,
- color: number,
- ) {
- const points: THREE.Vector3[] = []
- for (let i = 0; i <= divisions; i++) {
- const t = (i / divisions) * CUBE
- points.push(origin.clone().addScaledVector(vDir, t))
- points.push(origin.clone().addScaledVector(uDir, CUBE).addScaledVector(vDir, t))
- points.push(origin.clone().addScaledVector(uDir, t))
- points.push(origin.clone().addScaledVector(uDir, t).addScaledVector(vDir, CUBE))
+ function initScatter3DCharts() {
+ const wrappers = document.querySelectorAll('.chart-wrapper[data-chart-id^="scatter3d-chart"]')
+
+ wrappers.forEach((wrapper) => {
+ const container = wrapper.querySelector('div') as HTMLElement
+ if (!container) return
+
+ const rawData = (wrapper as HTMLElement).dataset.chartData
+ const chartData: Array<{ x: number; y: number; z: number; color?: string; label?: string }> = rawData
+ ? JSON.parse(rawData).map(
+ (d: { x: number; y: number; z?: number; color?: string; label?: string }) => ({
+ ...d,
+ z: d.z ?? 0,
+ }),
+ )
+ : []
+
+ if (!chartData.length) return
+
+ const colors: string[] = JSON.parse((wrapper as HTMLElement).dataset.chartColors || '[]')
+ const animated = (wrapper as HTMLElement).dataset.chartAnimated !== 'false'
+ const pointSize = parseFloat((wrapper as HTMLElement).dataset.chartPointSize || '8')
+ const pointOpacity = parseFloat((wrapper as HTMLElement).dataset.chartPointOpacity || '1')
+ const showGrid = (wrapper as HTMLElement).dataset.chartShowGrid !== 'false'
+ const xAxisConfig = JSON.parse((wrapper as HTMLElement).dataset.chartXAxis || '{}')
+ const yAxisConfig = JSON.parse((wrapper as HTMLElement).dataset.chartYAxis || '{}')
+ const zAxisConfig = JSON.parse((wrapper as HTMLElement).dataset.chartZAxis || '{}')
+ const chartHeight = parseInt((wrapper as HTMLElement).dataset.chartHeight || '400')
+ const chartTitle = (wrapper as HTMLElement).dataset.chartTitle || ''
+
+ const xMin = xAxisConfig.min ?? d3.min(chartData, (d) => d.x) ?? 0
+ const xMax = xAxisConfig.max ?? d3.max(chartData, (d) => d.x) ?? 1
+ const yMin = yAxisConfig.min ?? d3.min(chartData, (d) => d.y) ?? 0
+ const yMax = yAxisConfig.max ?? d3.max(chartData, (d) => d.y) ?? 1
+ const zMin = zAxisConfig.min ?? d3.min(chartData, (d) => d.z) ?? 0
+ const zMax = zAxisConfig.max ?? d3.max(chartData, (d) => d.z) ?? 1
+
+ const scaleX = d3.scaleLinear().domain([xMin, xMax]).range([0, CUBE])
+ const scaleY = d3.scaleLinear().domain([yMin, yMax]).range([0, CUBE])
+ const scaleZ = d3.scaleLinear().domain([zMin, zMax]).range([0, CUBE])
+
+ let scene: THREE.Scene
+ let camera: THREE.PerspectiveCamera
+ let renderer: THREE.WebGLRenderer
+ let controls: OrbitControls
+ let pointsGroup: THREE.Group
+ let raycaster: THREE.Raycaster
+ let mouse: THREE.Vector2
+ let hoveredMesh: THREE.Mesh | null = null
+
+ const DEFAULT_EYE = new THREE.Vector3(CUBE * 0.5, CUBE * 0.6, CUBE * 2.5)
+ const CENTER = new THREE.Vector3(CUBE / 2, CUBE / 2, CUBE / 2)
+
+ function makeLabel(
+ text: string,
+ position: THREE.Vector3,
+ colorHex: number,
+ scaleW = 0.28,
+ scaleH = 0.1,
+ fontPx = 28,
+ ) {
+ const canvas = document.createElement('canvas')
+ canvas.width = 256
+ canvas.height = 64
+ const ctx = canvas.getContext('2d')!
+ ctx.clearRect(0, 0, 256, 64)
+ ctx.fillStyle = `#${colorHex.toString(16).padStart(6, '0')}`
+ ctx.font = `${fontPx}px monospace`
+ ctx.textAlign = 'center'
+ ctx.textBaseline = 'middle'
+ ctx.fillText(text, 128, 32)
+ const texture = new THREE.CanvasTexture(canvas)
+ texture.needsUpdate = true
+ const mat = new THREE.SpriteMaterial({ map: texture, transparent: true, depthTest: false })
+ const sprite = new THREE.Sprite(mat)
+ sprite.position.copy(position)
+ sprite.scale.set(scaleW, scaleH, 1)
+ return sprite
}
- const geo = new THREE.BufferGeometry().setFromPoints(points)
- const mat = new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.35 })
- return new THREE.LineSegments(geo, mat)
- }
-
- function initChart() {
- container.innerHTML = ''
- const isDark = document.documentElement.classList.contains('dark')
- const textColor = isDark ? 0xd4d4d8 : 0x52525b
- const gridColor = isDark ? 0x3f3f46 : 0xdde1e7
- const axisColor = isDark ? 0x71717a : 0x94a3b8
- const faceColor = isDark ? 0x18181b : 0xf1f5f9
- const bgCss = isDark ? '#0c0c0e' : '#f8fafc'
-
- if (chartTitle) {
- const titleEl = document.createElement('div')
- titleEl.textContent = chartTitle
- titleEl.style.cssText = `
+
+ function makeFaceGrid(
+ origin: THREE.Vector3,
+ uDir: THREE.Vector3,
+ vDir: THREE.Vector3,
+ divisions: number,
+ color: number,
+ ) {
+ const points: THREE.Vector3[] = []
+ for (let i = 0; i <= divisions; i++) {
+ const t = (i / divisions) * CUBE
+ points.push(origin.clone().addScaledVector(vDir, t))
+ points.push(origin.clone().addScaledVector(uDir, CUBE).addScaledVector(vDir, t))
+ points.push(origin.clone().addScaledVector(uDir, t))
+ points.push(origin.clone().addScaledVector(uDir, t).addScaledVector(vDir, CUBE))
+ }
+ const geo = new THREE.BufferGeometry().setFromPoints(points)
+ const mat = new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.35 })
+ return new THREE.LineSegments(geo, mat)
+ }
+
+ function initChart() {
+ container.innerHTML = ''
+ const isDark = document.documentElement.classList.contains('dark')
+ const textColor = isDark ? 0xd4d4d8 : 0x52525b
+ const gridColor = isDark ? 0x3f3f46 : 0xdde1e7
+ const axisColor = isDark ? 0x71717a : 0x94a3b8
+ const faceColor = isDark ? 0x18181b : 0xf1f5f9
+ const bgCss = isDark ? '#0c0c0e' : '#f8fafc'
+
+ if (chartTitle) {
+ const titleEl = document.createElement('div')
+ titleEl.textContent = chartTitle
+ titleEl.style.cssText = `
font-size: 15px; font-weight: 600; text-align: center;
color: ${isDark ? '#e4e4e7' : '#27272a'}; margin-bottom: 4px;
`
- container.prepend(titleEl)
- }
-
- const width = container.clientWidth
- const h = chartHeight
-
- scene = new THREE.Scene()
-
- scene.add(new THREE.AmbientLight(0xffffff, 0.7))
- const dLight = new THREE.DirectionalLight(0xffffff, 0.9)
- dLight.position.set(3, 5, 3)
- scene.add(dLight)
- const dLight2 = new THREE.DirectionalLight(0xffffff, 0.25)
- dLight2.position.set(-2, -1, -2)
- scene.add(dLight2)
-
- camera = new THREE.PerspectiveCamera(40, width / h, 0.01, 200)
- camera.position.copy(DEFAULT_EYE)
- camera.lookAt(CENTER)
-
- renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true })
- renderer.setSize(width, h)
- renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
- renderer.setClearColor(0x000000, 0)
- renderer.domElement.style.cssText = `position:absolute;top:0;left:0;z-index:1;border-radius:8px;`
- container.style.position = 'relative'
- container.appendChild(renderer.domElement)
-
- const tooltip = createTooltip(container)
-
- controls = new OrbitControls(camera, renderer.domElement)
- controls.enableDamping = true
- controls.dampingFactor = 0.06
- controls.rotateSpeed = 0.7
- controls.zoomSpeed = 1.2
- controls.minDistance = CUBE * 0.5
- controls.maxDistance = CUBE * 8
- controls.target.copy(CENTER)
- controls.update()
-
- const corners = [
- [0, 0, 0],
- [CUBE, 0, 0],
- [CUBE, CUBE, 0],
- [0, CUBE, 0],
- [0, 0, CUBE],
- [CUBE, 0, CUBE],
- [CUBE, CUBE, CUBE],
- [0, CUBE, CUBE],
- ].map(([x, y, z]) => new THREE.Vector3(x, y, z))
-
- const edgePairs = [
- [0, 1],
- [1, 2],
- [2, 3],
- [3, 0],
- [4, 5],
- [5, 6],
- [6, 7],
- [7, 4],
- [0, 4],
- [1, 5],
- [2, 6],
- [3, 7],
- ]
- const edgePoints: THREE.Vector3[] = []
- edgePairs.forEach(([a, b]) => {
- edgePoints.push(corners[a], corners[b])
- })
- const edgeGeo = new THREE.BufferGeometry().setFromPoints(edgePoints)
- const edgeMat = new THREE.LineBasicMaterial({ color: axisColor, transparent: true, opacity: 0.6 })
- scene.add(new THREE.LineSegments(edgeGeo, edgeMat))
+ container.prepend(titleEl)
+ }
- if (showGrid) {
- const DIVS = 6
- scene.add(
- makeFaceGrid(
- new THREE.Vector3(0, 0, 0),
- new THREE.Vector3(1, 0, 0),
- new THREE.Vector3(0, 0, 1),
- DIVS,
- gridColor,
- ),
- )
- scene.add(
- makeFaceGrid(
- new THREE.Vector3(0, 0, 0),
- new THREE.Vector3(1, 0, 0),
- new THREE.Vector3(0, 1, 0),
- DIVS,
- gridColor,
- ),
- )
- scene.add(
- makeFaceGrid(
- new THREE.Vector3(0, 0, 0),
- new THREE.Vector3(0, 0, 1),
- new THREE.Vector3(0, 1, 0),
- DIVS,
- gridColor,
- ),
- )
- }
+ const width = container.clientWidth
+ const h = chartHeight
+
+ scene = new THREE.Scene()
+
+ scene.add(new THREE.AmbientLight(0xffffff, 0.7))
+ const dLight = new THREE.DirectionalLight(0xffffff, 0.9)
+ dLight.position.set(3, 5, 3)
+ scene.add(dLight)
+ const dLight2 = new THREE.DirectionalLight(0xffffff, 0.25)
+ dLight2.position.set(-2, -1, -2)
+ scene.add(dLight2)
+
+ camera = new THREE.PerspectiveCamera(40, width / h, 0.01, 200)
+ camera.position.copy(DEFAULT_EYE)
+ camera.lookAt(CENTER)
+
+ renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true })
+ renderer.setSize(width, h)
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
+ renderer.setClearColor(0x000000, 0)
+ renderer.domElement.style.cssText = `position:absolute;top:0;left:0;z-index:1;border-radius:8px;`
+ container.style.position = 'relative'
+ container.appendChild(renderer.domElement)
+
+ const tooltip = createTooltip(container)
+
+ controls = new OrbitControls(camera, renderer.domElement)
+ controls.enableDamping = true
+ controls.dampingFactor = 0.06
+ controls.rotateSpeed = 0.7
+ controls.zoomSpeed = 1.2
+ controls.minDistance = CUBE * 0.5
+ controls.maxDistance = CUBE * 8
+ controls.target.copy(CENTER)
+ controls.update()
- const TICKS = 5
- const tickOffset = CUBE * 0.02
+ const corners = [
+ [0, 0, 0],
+ [CUBE, 0, 0],
+ [CUBE, CUBE, 0],
+ [0, CUBE, 0],
+ [0, 0, CUBE],
+ [CUBE, 0, CUBE],
+ [CUBE, CUBE, CUBE],
+ [0, CUBE, CUBE],
+ ].map(([x, y, z]) => new THREE.Vector3(x, y, z))
+
+ const edgePairs = [
+ [0, 1],
+ [1, 2],
+ [2, 3],
+ [3, 0],
+ [4, 5],
+ [5, 6],
+ [6, 7],
+ [7, 4],
+ [0, 4],
+ [1, 5],
+ [2, 6],
+ [3, 7],
+ ]
+ const edgePoints: THREE.Vector3[] = []
+ edgePairs.forEach(([a, b]) => {
+ edgePoints.push(corners[a], corners[b])
+ })
+ const edgeGeo = new THREE.BufferGeometry().setFromPoints(edgePoints)
+ const edgeMat = new THREE.LineBasicMaterial({ color: axisColor, transparent: true, opacity: 0.6 })
+ scene.add(new THREE.LineSegments(edgeGeo, edgeMat))
+
+ if (showGrid) {
+ const DIVS = 6
+ scene.add(
+ makeFaceGrid(
+ new THREE.Vector3(0, 0, 0),
+ new THREE.Vector3(1, 0, 0),
+ new THREE.Vector3(0, 0, 1),
+ DIVS,
+ gridColor,
+ ),
+ )
+ scene.add(
+ makeFaceGrid(
+ new THREE.Vector3(0, 0, 0),
+ new THREE.Vector3(1, 0, 0),
+ new THREE.Vector3(0, 1, 0),
+ DIVS,
+ gridColor,
+ ),
+ )
+ scene.add(
+ makeFaceGrid(
+ new THREE.Vector3(0, 0, 0),
+ new THREE.Vector3(0, 0, 1),
+ new THREE.Vector3(0, 1, 0),
+ DIVS,
+ gridColor,
+ ),
+ )
+ }
- for (let i = 0; i <= TICKS; i++) {
- const t = i / TICKS
- const xVal = xMin + t * (xMax - xMin)
- const yVal = yMin + t * (yMax - yMin)
- const zVal = zMin + t * (zMax - zMin)
- const xW = t * CUBE
- const yW = t * CUBE
- const zW = t * CUBE
+ const TICKS = 5
+ const tickOffset = CUBE * 0.02
+
+ for (let i = 0; i <= TICKS; i++) {
+ const t = i / TICKS
+ const xVal = xMin + t * (xMax - xMin)
+ const yVal = yMin + t * (yMax - yMin)
+ const zVal = zMin + t * (zMax - zMin)
+ const xW = t * CUBE
+ const yW = t * CUBE
+ const zW = t * CUBE
+
+ scene.add(
+ makeLabel(
+ xVal.toFixed(1),
+ new THREE.Vector3(xW, -tickOffset, CUBE + tickOffset),
+ textColor,
+ 0.2,
+ 0.08,
+ 24,
+ ),
+ )
+ scene.add(
+ makeLabel(
+ yVal.toFixed(1),
+ new THREE.Vector3(-tickOffset * 1.5, yW, CUBE + tickOffset),
+ textColor,
+ 0.2,
+ 0.08,
+ 24,
+ ),
+ )
+ scene.add(
+ makeLabel(
+ zVal.toFixed(1),
+ new THREE.Vector3(-tickOffset * 1.5, -tickOffset, zW),
+ textColor,
+ 0.2,
+ 0.08,
+ 24,
+ ),
+ )
+ }
+ const xLabel = xAxisConfig.label || 'X'
+ const yLabel = yAxisConfig.label || 'Y'
+ const zLabel = zAxisConfig.label || 'Z'
scene.add(
makeLabel(
- xVal.toFixed(1),
- new THREE.Vector3(xW, -tickOffset, CUBE + tickOffset),
+ xLabel,
+ new THREE.Vector3(CUBE / 2, -tickOffset * 2.5, CUBE + tickOffset * 2),
textColor,
- 0.2,
- 0.08,
- 24,
+ 0.3,
+ 0.1,
+ 30,
),
)
scene.add(
makeLabel(
- yVal.toFixed(1),
- new THREE.Vector3(-tickOffset * 1.5, yW, CUBE + tickOffset),
+ yLabel,
+ new THREE.Vector3(-tickOffset * 3, CUBE / 2, CUBE + tickOffset * 2),
textColor,
- 0.2,
- 0.08,
- 24,
+ 0.3,
+ 0.1,
+ 30,
),
)
scene.add(
makeLabel(
- zVal.toFixed(1),
- new THREE.Vector3(-tickOffset * 1.5, -tickOffset, zW),
+ zLabel,
+ new THREE.Vector3(-tickOffset * 3, -tickOffset * 2.5, CUBE / 2),
textColor,
- 0.2,
- 0.08,
- 24,
+ 0.3,
+ 0.1,
+ 30,
),
)
- }
- const xLabel = xAxisConfig.label || 'X'
- const yLabel = yAxisConfig.label || 'Y'
- const zLabel = zAxisConfig.label || 'Z'
- scene.add(
- makeLabel(
- xLabel,
- new THREE.Vector3(CUBE / 2, -tickOffset * 2.5, CUBE + tickOffset * 2),
- textColor,
- 0.3,
- 0.1,
- 30,
- ),
- )
- scene.add(
- makeLabel(
- yLabel,
- new THREE.Vector3(-tickOffset * 3, CUBE / 2, CUBE + tickOffset * 2),
- textColor,
- 0.3,
- 0.1,
- 30,
- ),
- )
- scene.add(
- makeLabel(
- zLabel,
- new THREE.Vector3(-tickOffset * 3, -tickOffset * 2.5, CUBE / 2),
- textColor,
- 0.3,
- 0.1,
- 30,
- ),
- )
-
- pointsGroup = new THREE.Group()
- const sphereGeo = new THREE.SphereGeometry(pointSize * 0.005, 20, 20)
-
- chartData.forEach((d, i) => {
- const color = new THREE.Color(d.color || getColor(i, colors))
- const mat = new THREE.MeshStandardMaterial({
- color,
- transparent: pointOpacity < 1,
- opacity: pointOpacity,
- roughness: 0.35,
- metalness: 0.15,
+ pointsGroup = new THREE.Group()
+ const sphereGeo = new THREE.SphereGeometry(pointSize * 0.005, 20, 20)
+
+ chartData.forEach((d, i) => {
+ const color = new THREE.Color(d.color || getColor(i, colors))
+ const mat = new THREE.MeshStandardMaterial({
+ color,
+ transparent: pointOpacity < 1,
+ opacity: pointOpacity,
+ roughness: 0.35,
+ metalness: 0.15,
+ })
+ const mesh = new THREE.Mesh(sphereGeo, mat)
+ mesh.position.set(scaleX(d.x), scaleY(d.y), scaleZ(d.z))
+ mesh.userData = { index: i, originalColor: color.clone() }
+ pointsGroup.add(mesh)
})
- const mesh = new THREE.Mesh(sphereGeo, mat)
- mesh.position.set(scaleX(d.x), scaleY(d.y), scaleZ(d.z))
- mesh.userData = { index: i, originalColor: color.clone() }
- pointsGroup.add(mesh)
- })
- scene.add(pointsGroup)
-
- if (animated) {
- pointsGroup.scale.setScalar(0)
- const t0 = performance.now()
- const dur = 700
- function animateIn() {
- const p = Math.min((performance.now() - t0) / dur, 1)
- pointsGroup.scale.setScalar(1 - Math.pow(1 - p, 3))
- if (p < 1) requestAnimationFrame(animateIn)
+ scene.add(pointsGroup)
+
+ if (animated) {
+ pointsGroup.scale.setScalar(0)
+ const t0 = performance.now()
+ const dur = 700
+ function animateIn() {
+ const p = Math.min((performance.now() - t0) / dur, 1)
+ pointsGroup.scale.setScalar(1 - Math.pow(1 - p, 3))
+ if (p < 1) requestAnimationFrame(animateIn)
+ }
+ animateIn()
}
- animateIn()
- }
- wrapper.querySelectorAll('.chart-btn').forEach((btn) => {
- btn.addEventListener('click', () => {
- if ((btn as HTMLElement).classList.contains('zoom-in')) {
- controls.dollyIn(1.4)
- } else if ((btn as HTMLElement).classList.contains('zoom-out')) {
- controls.dollyOut(1.4)
- } else {
- // Smooth reset
- const resetStart = performance.now()
- const fromPos = camera.position.clone()
- const toPos = DEFAULT_EYE.clone()
- function resetAnim() {
- const p = Math.min((performance.now() - resetStart) / 400, 1)
- const e = 1 - Math.pow(1 - p, 3)
- camera.position.lerpVectors(fromPos, toPos, e)
- controls.target.copy(CENTER)
- controls.update()
- if (p < 1) requestAnimationFrame(resetAnim)
+ wrapper.querySelectorAll('.chart-btn').forEach((btn) => {
+ btn.addEventListener('click', () => {
+ if ((btn as HTMLElement).classList.contains('zoom-in')) {
+ controls.dollyIn(1.4)
+ } else if ((btn as HTMLElement).classList.contains('zoom-out')) {
+ controls.dollyOut(1.4)
+ } else {
+ // Smooth reset
+ const resetStart = performance.now()
+ const fromPos = camera.position.clone()
+ const toPos = DEFAULT_EYE.clone()
+ function resetAnim() {
+ const p = Math.min((performance.now() - resetStart) / 400, 1)
+ const e = 1 - Math.pow(1 - p, 3)
+ camera.position.lerpVectors(fromPos, toPos, e)
+ controls.target.copy(CENTER)
+ controls.update()
+ if (p < 1) requestAnimationFrame(resetAnim)
+ }
+ resetAnim()
}
- resetAnim()
- }
+ })
})
- })
-
- raycaster = new THREE.Raycaster()
- raycaster.params.Points = { threshold: 0.02 }
- mouse = new THREE.Vector2()
-
- renderer.domElement.addEventListener('mousemove', (e: MouseEvent) => {
- const rect = renderer.domElement.getBoundingClientRect()
- mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1
- mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1
- raycaster.setFromCamera(mouse, camera)
- const hits = raycaster.intersectObjects(pointsGroup.children, false)
+ raycaster = new THREE.Raycaster()
+ raycaster.params.Points = { threshold: 0.02 }
+ mouse = new THREE.Vector2()
+
+ renderer.domElement.addEventListener('mousemove', (e: MouseEvent) => {
+ const rect = renderer.domElement.getBoundingClientRect()
+ mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1
+ mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1
+
+ raycaster.setFromCamera(mouse, camera)
+ const hits = raycaster.intersectObjects(pointsGroup.children, false)
+
+ if (hoveredMesh && (!hits.length || hits[0].object !== hoveredMesh)) {
+ ;(hoveredMesh.material as THREE.MeshStandardMaterial).color.copy(
+ hoveredMesh.userData.originalColor,
+ )
+ ;(hoveredMesh.material as THREE.MeshStandardMaterial).emissive.set(0x000000)
+ hoveredMesh = null
+ hideTooltip(tooltip)
+ }
- if (hoveredMesh && (!hits.length || hits[0].object !== hoveredMesh)) {
- ;(hoveredMesh.material as THREE.MeshStandardMaterial).color.copy(hoveredMesh.userData.originalColor)
- ;(hoveredMesh.material as THREE.MeshStandardMaterial).emissive.set(0x000000)
- hoveredMesh = null
- hideTooltip(tooltip)
- }
+ renderer.domElement.style.cursor = hits.length ? 'pointer' : 'grab'
- renderer.domElement.style.cursor = hits.length ? 'pointer' : 'grab'
+ if (hits.length) {
+ const mesh = hits[0].object as THREE.Mesh
+ if (mesh !== hoveredMesh) {
+ hoveredMesh = mesh
+ ;(mesh.material as THREE.MeshStandardMaterial).color.set(0xffffff)
+ ;(mesh.material as THREE.MeshStandardMaterial).emissive.set(0x444444)
+ }
+ const d = chartData[mesh.userData.index]
+ const lbl = d.label ? `${d.label}
` : ''
+ showTooltip(
+ tooltip,
+ e.offsetX,
+ e.offsetY,
+ `${lbl}x: ${d.x.toFixed(2)}
y: ${d.y.toFixed(2)}
z: ${d.z.toFixed(2)}`,
+ container.getBoundingClientRect(),
+ )
+ }
+ })
- if (hits.length) {
- const mesh = hits[0].object as THREE.Mesh
- if (mesh !== hoveredMesh) {
- hoveredMesh = mesh
- ;(mesh.material as THREE.MeshStandardMaterial).color.set(0xffffff)
- ;(mesh.material as THREE.MeshStandardMaterial).emissive.set(0x444444)
+ renderer.domElement.addEventListener('click', (e: MouseEvent) => {
+ const rect = renderer.domElement.getBoundingClientRect()
+ mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1
+ mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1
+ raycaster.setFromCamera(mouse, camera)
+ const hits = raycaster.intersectObjects(pointsGroup.children, false)
+ if (hits.length) {
+ const d = chartData[(hits[0].object as THREE.Mesh).userData.index]
+ container.dispatchEvent(
+ new CustomEvent('chart:click', {
+ bubbles: true,
+ detail: { type: 'scatter3d', x: d.x, y: d.y, z: d.z, label: d.label },
+ }),
+ )
}
- const d = chartData[mesh.userData.index]
- const lbl = d.label ? `${d.label}
` : ''
- showTooltip(
- tooltip,
- e.offsetX,
- e.offsetY,
- `${lbl}x: ${d.x.toFixed(2)}
y: ${d.y.toFixed(2)}
z: ${d.z.toFixed(2)}`,
- container.getBoundingClientRect(),
- )
- }
- })
+ })
- renderer.domElement.addEventListener('click', (e: MouseEvent) => {
- const rect = renderer.domElement.getBoundingClientRect()
- mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1
- mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1
- raycaster.setFromCamera(mouse, camera)
- const hits = raycaster.intersectObjects(pointsGroup.children, false)
- if (hits.length) {
- const d = chartData[(hits[0].object as THREE.Mesh).userData.index]
- container.dispatchEvent(
- new CustomEvent('chart:click', {
- bubbles: true,
- detail: { type: 'scatter3d', x: d.x, y: d.y, z: d.z, label: d.label },
- }),
- )
+ let rafId: number
+ function animate() {
+ rafId = requestAnimationFrame(animate)
+ controls.update()
+ renderer.render(scene, camera)
+ }
+ animate()
+ ;(container as any).__cleanup = () => {
+ cancelAnimationFrame(rafId)
+ renderer.dispose()
}
+ }
+
+ const resizeObs = new ResizeObserver(() => {
+ if (!renderer || !camera) return
+ const w = container.clientWidth
+ camera.aspect = w / chartHeight
+ camera.updateProjectionMatrix()
+ renderer.setSize(w, chartHeight)
})
+ resizeObs.observe(container)
- let rafId: number
- function animate() {
- rafId = requestAnimationFrame(animate)
- controls.update()
- renderer.render(scene, camera)
- }
- animate()
- ;(container as any).__cleanup = () => {
- cancelAnimationFrame(rafId)
- renderer.dispose()
- }
- }
-
- const resizeObs = new ResizeObserver(() => {
- if (!renderer || !camera) return
- const w = container.clientWidth
- camera.aspect = w / chartHeight
- camera.updateProjectionMatrix()
- renderer.setSize(w, chartHeight)
+ initChart()
})
- resizeObs.observe(container)
+ }
- initChart()
- })
+ onHydration(initScatter3DCharts)
diff --git a/src/utils/hydration.ts b/src/utils/hydration.ts
new file mode 100644
index 0000000..bc1b50f
--- /dev/null
+++ b/src/utils/hydration.ts
@@ -0,0 +1,4 @@
+export function onHydration(init: () => void): void {
+ init()
+ document.addEventListener('astro:after-swap', init)
+}