454 lines
14 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
}
|