TkAstral3D/packages/sdk/lib/core/objects/Heatmap.ts
2026-04-08 15:34:43 +08:00

454 lines
14 KiB
TypeScript

import * as THREE from "three";
import h337 from "heatmap.js-fix";
import { deepAssign, deepEqual } from "@/utils";
type HeatmapRenderer = {
canvas: HTMLCanvasElement;
};
type HeatmapInstance = {
setData: (data: { max: number; min?: number; data: HeatmapCanvasPoint[] }) => void;
configure?: (config: Record<string, unknown>) => void;
_renderer?: HeatmapRenderer;
_store?: { _cfgRadius?: number };
};
type HeatmapCanvasPoint = {
x: number;
y: number;
value: number;
radius?: number;
};
export const getDefaultHeatmapOptions = (): IHeatmap.options => ({
name: "Heatmap",
position: [0, 0, 0],
mode: "flat",
size: {
width: 10,
height: 10,
},
resolution: {
width: 512,
height: 512,
},
material: {
transparent: true,
opacity: 1,
depthWrite: false,
depthTest: true,
side: "double",
},
height: {
scale: 2,
segments: {
width: 128,
height: 128,
},
},
heatmap: {
radius: 40,
blur: 0.85,
maxOpacity: 1,
minOpacity: 0,
gradient: {
"0.0": "#2c7bb6",
"0.5": "#ffffbf",
"1.0": "#d7191c",
},
},
data: {
max: 1,
min: 0,
points: [],
},
});
const DEFAULT_HEATMAP_RADIUS = getDefaultHeatmapOptions().heatmap.radius ?? 0;
export default class Heatmap extends THREE.Mesh {
type = "Heatmap";
isHeatmap = true;
options: IHeatmap.options = getDefaultHeatmapOptions();
heatmap: HeatmapInstance;
container: HTMLDivElement;
canvas: HTMLCanvasElement;
texture: THREE.CanvasTexture;
constructor(options: Partial<IHeatmap.options> = {}) {
super();
if (typeof document === "undefined") {
throw new Error("[Astral 3D]: Heatmap requires a DOM environment.");
}
deepAssign(this.options, options);
if (options.heatmap?.gradient) {
this.options.heatmap.gradient = { ...options.heatmap.gradient };
}
this.name = this.options.name;
this.container = this.createContainer();
this.heatmap = this.createHeatmap();
this.canvas = this.resolveCanvas();
this.texture = new THREE.CanvasTexture(this.canvas);
this.texture.colorSpace = THREE.SRGBColorSpace;
this.material = new THREE.MeshBasicMaterial({
map: this.texture,
transparent: this.options.material.transparent,
opacity: this.options.material.opacity,
depthWrite: this.options.material.depthWrite,
depthTest: this.options.material.depthTest,
side: this.resolveSide(this.options.material.side),
});
this.geometry = this.createGeometry();
const position = this.options.position;
this.position.set(position[0] || 0, position[1] || 0, position[2] || 0);
// Default to horizontal (XZ) plane with Y-up.
this.rotation.x = -Math.PI / 2;
if (this.options.data?.points?.length) {
this.setData(this.options.data);
} else {
this.heatmap.setData({
max: this.options.data.max || 1,
min: this.options.data.min || 0,
data: [],
});
}
}
private createContainer() {
const container = document.createElement("div");
const { width, height } = this.options.resolution;
container.style.position = "absolute";
container.style.left = "-10000px";
container.style.top = "-10000px";
container.style.width = `${width}px`;
container.style.height = `${height}px`;
container.style.visibility = "hidden";
container.style.pointerEvents = "none";
const host = document.body || document.documentElement;
host.appendChild(container);
return container;
}
private createHeatmap() {
const config = {
container: this.container,
radius: this.options.heatmap.radius,
blur: this.options.heatmap.blur,
maxOpacity: this.options.heatmap.maxOpacity,
minOpacity: this.options.heatmap.minOpacity,
gradient: this.options.heatmap.gradient,
};
return (h337 as { create: (cfg: Record<string, unknown>) => HeatmapInstance }).create(config);
}
private clearContainer() {
while (this.container.firstChild) {
this.container.removeChild(this.container.firstChild);
}
}
private refreshCanvas() {
const canvas = this.resolveCanvas();
if (canvas !== this.canvas) {
this.canvas = canvas;
this.texture.image = this.canvas;
}
this.texture.needsUpdate = true;
}
private rebuildHeatmap() {
this.clearContainer();
this.heatmap = this.createHeatmap();
this.refreshCanvas();
}
private syncHeatmapRuntimeConfig() {
const heatmapAny = this.heatmap as HeatmapInstance | undefined;
if (!heatmapAny) return;
if (heatmapAny._store && typeof this.options.heatmap.radius === "number") {
heatmapAny._store._cfgRadius = this.options.heatmap.radius;
}
const rendererAny = heatmapAny._renderer as { _templates?: Record<string, HTMLCanvasElement> } | undefined;
if (rendererAny && rendererAny._templates) {
rendererAny._templates = {};
}
}
private getSegments() {
if (this.options.mode !== "height") {
return { width: 1, height: 1 };
}
const segments = this.options.height?.segments || { width: 1, height: 1 };
return {
width: Math.max(1, Math.floor(segments.width)),
height: Math.max(1, Math.floor(segments.height)),
};
}
private createGeometry() {
const size = this.options.size;
const segments = this.getSegments();
return new THREE.PlaneGeometry(size.width, size.height, segments.width, segments.height);
}
private ensureGeometry() {
const size = this.options.size;
const segments = this.getSegments();
const geometry = this.geometry as THREE.PlaneGeometry | undefined;
const parameters = geometry?.parameters;
const needsRebuild =
!parameters ||
parameters.width !== size.width ||
parameters.height !== size.height ||
parameters.widthSegments !== segments.width ||
parameters.heightSegments !== segments.height;
if (!needsRebuild) return;
this.geometry.dispose();
this.geometry = new THREE.PlaneGeometry(size.width, size.height, segments.width, segments.height);
}
private resolveCanvas() {
const canvas = this.heatmap._renderer?.canvas || this.container.querySelector("canvas");
if (!canvas) {
const fallback = document.createElement("canvas");
this.container.appendChild(fallback);
return fallback;
}
canvas.width = this.options.resolution.width;
canvas.height = this.options.resolution.height;
return canvas;
}
private flattenGeometry() {
const geometry = this.geometry as THREE.BufferGeometry;
const position = geometry.getAttribute("position") as THREE.BufferAttribute | undefined;
if (!position) return;
for (let i = 0; i < position.count; i += 1) {
position.setZ(i, 0);
}
position.needsUpdate = true;
geometry.computeVertexNormals();
}
private updateRelief() {
this.ensureGeometry();
if (this.options.mode !== "height") {
this.flattenGeometry();
return;
}
const context = this.canvas.getContext("2d");
if (!context) return;
const canvasWidth = this.canvas.width;
const canvasHeight = this.canvas.height;
const imageData = context.getImageData(0, 0, canvasWidth, canvasHeight).data;
const geometry = this.geometry as THREE.BufferGeometry;
const position = geometry.getAttribute("position") as THREE.BufferAttribute | undefined;
const uv = geometry.getAttribute("uv") as THREE.BufferAttribute | undefined;
if (!position || !uv) return;
const heightScale = this.options.height?.scale ?? 1;
for (let i = 0; i < position.count; i += 1) {
const u = uv.getX(i);
const v = uv.getY(i);
const x = Math.round(this.clamp(u * (canvasWidth - 1), 0, canvasWidth - 1));
const y = Math.round(this.clamp((1 - v) * (canvasHeight - 1), 0, canvasHeight - 1));
const index = (y * canvasWidth + x) * 4;
const alpha = imageData[index + 3] / 255;
position.setZ(i, alpha * heightScale);
}
position.needsUpdate = true;
geometry.computeVertexNormals();
}
private resolveSide(side?: IHeatmap.Side) {
switch (side) {
case "front":
return THREE.FrontSide;
case "back":
return THREE.BackSide;
case "double":
default:
return THREE.DoubleSide;
}
}
private clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
private toCanvasPoint(point: IHeatmap.Point): HeatmapCanvasPoint {
const size = this.options.size;
const resolution = this.options.resolution;
const safeWidth = Math.max(size.width, 0.000001);
const safeHeight = Math.max(size.height, 0.000001);
const scaleX = resolution.width / safeWidth;
const scaleY = resolution.height / safeHeight;
// Map local plane coordinates (centered at origin) to heatmap pixels.
const x = (point.x + size.width / 2) * scaleX;
const y = (size.height / 2 - point.y) * scaleY;
const canvasPoint: HeatmapCanvasPoint = {
x: this.clamp(x, 0, resolution.width),
y: this.clamp(y, 0, resolution.height),
value: point.value,
};
if (typeof point.radius === "number") {
const radiusScale = Math.min(scaleX, scaleY);
const baseRadius = typeof this.options.heatmap.radius === "number" ? this.options.heatmap.radius : DEFAULT_HEATMAP_RADIUS;
const radiusFactor = DEFAULT_HEATMAP_RADIUS > 0 ? Math.max(0, baseRadius) / DEFAULT_HEATMAP_RADIUS : 1;
canvasPoint.radius = point.radius * radiusScale * radiusFactor;
}
return canvasPoint;
}
private buildHeatmapData(data: IHeatmap.Data) {
const points = data.points || [];
const values = points.map((point) => point.value).filter((value) => Number.isFinite(value));
const max = typeof data.max === "number" ? data.max : values.length ? Math.max(...values) : 1;
const min = typeof data.min === "number" ? data.min : values.length ? Math.min(...values) : 0;
return {
max,
min,
data: points.map((point) => this.toCanvasPoint(point)),
};
}
setData(data: IHeatmap.Data) {
this.options.data = {
max: data.max,
min: data.min,
points: Array.isArray(data.points) ? data.points.slice() : [],
};
const heatmapData = this.buildHeatmapData(this.options.data);
this.heatmap.setData(heatmapData);
this.texture.needsUpdate = true;
this.updateRelief();
}
addData(points: IHeatmap.Point | IHeatmap.Point[]) {
const list = Array.isArray(points) ? points : [points];
const next = this.options.data.points.concat(list);
this.setData({
...this.options.data,
points: next,
});
}
clear() {
this.setData({
max: 1,
min: 0,
points: [],
});
return this;
}
setSize(size: IHeatmap.Size) {
this.options.size = { width: size.width, height: size.height };
this.ensureGeometry();
this.setData(this.options.data);
}
setMode(mode: IHeatmap.Mode) {
if (this.options.mode === mode) return this;
this.options.mode = mode;
this.ensureGeometry();
this.updateRelief();
return this;
}
updateHeatmapConfig(config: Partial<IHeatmap.HeatmapConfig>) {
const previousGradient = this.options.heatmap.gradient;
const { gradient, ...rest } = config;
deepAssign(this.options.heatmap, rest);
if (gradient && typeof gradient === "object" && !Array.isArray(gradient)) {
this.options.heatmap.gradient = { ...gradient };
}
const gradientChanged = !deepEqual(previousGradient, this.options.heatmap.gradient);
this.syncHeatmapRuntimeConfig();
if (gradientChanged) {
this.rebuildHeatmap();
} else if (this.heatmap.configure) {
this.heatmap.configure({
radius: this.options.heatmap.radius,
blur: this.options.heatmap.blur,
maxOpacity: this.options.heatmap.maxOpacity,
minOpacity: this.options.heatmap.minOpacity,
gradient: this.options.heatmap.gradient,
});
this.refreshCanvas();
} else {
this.rebuildHeatmap();
}
this.setData(this.options.data);
}
toJSON(meta?: THREE.JSONMeta) {
const options = JSON.parse(JSON.stringify(this.options));
options.position = this.position.toArray();
// 同步材质属性
options.material = {
transparent: (this.material as THREE.Material).transparent,
opacity: (this.material as THREE.Material).opacity,
depthWrite: (this.material as THREE.Material).depthWrite,
depthTest: (this.material as THREE.Material).depthTest,
side: this.options.material.side,
};
const superJSON = super.toJSON(meta);
superJSON.object.type = this.type;
superJSON.object.options = options;
return superJSON;
}
static fromJSON(json: { options: IHeatmap.options }) {
return new Heatmap(json.options);
}
dispose() {
this.geometry.dispose();
this.texture.dispose();
(this.material as THREE.Material).dispose();
if (this.container.parentElement) {
this.container.parentElement.removeChild(this.container);
}
}
}