moved from a general heatmap to a more spatial hot cold point around nodes that the density and brightness are controlled by how far past the threshold the nodes temp is
This commit is contained in:
parent
6c87ed03ed
commit
6cc40e159a
9 changed files with 493 additions and 26 deletions
|
|
@ -14,6 +14,7 @@ export interface BaseMeshProps {
|
|||
materialProps?: Partial<MaterialParameters>;
|
||||
materialOverride?: React.ReactNode;
|
||||
side?: Side
|
||||
depthWrite?: boolean
|
||||
}
|
||||
|
||||
export default function BaseMesh(props: BaseMeshProps) {
|
||||
|
|
@ -30,7 +31,8 @@ export default function BaseMesh(props: BaseMeshProps) {
|
|||
materialType,
|
||||
materialProps,
|
||||
materialOverride,
|
||||
side
|
||||
side,
|
||||
depthWrite = true
|
||||
} = props;
|
||||
|
||||
const isTransparent = opacity < 1;
|
||||
|
|
@ -43,7 +45,7 @@ export default function BaseMesh(props: BaseMeshProps) {
|
|||
color={colour}
|
||||
transparent={isTransparent}
|
||||
opacity={opacity}
|
||||
depthWrite={!isTransparent}
|
||||
depthWrite={depthWrite}
|
||||
side={side}
|
||||
{...materialProps}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ export default function BinShell(props: Props) {
|
|||
wireframe= {wireframe}
|
||||
metalness={binMetalness}
|
||||
position={roofPosition}
|
||||
roughness={binRoughness}
|
||||
roughness={binRoughness}
|
||||
side={2}
|
||||
opacity={binOpacity}/>
|
||||
{/* bin sidewall - cylinder (open ended for the roof and bottom)*/}
|
||||
|
|
@ -93,7 +93,7 @@ export default function BinShell(props: Props) {
|
|||
colour={binBodyColour}
|
||||
wireframe= {wireframe}
|
||||
metalness={binMetalness}
|
||||
roughness={binRoughness}
|
||||
roughness={binRoughness}
|
||||
opacity={binOpacity}/>
|
||||
{/* bin bottom - cone -OR- circle depending on the bins shape*/}
|
||||
{hopperHeight !== undefined && hopperHeight > 0 ?
|
||||
|
|
|
|||
|
|
@ -33,17 +33,28 @@ export function BuildNodeData(cables: CableData[]): NodeData[] {
|
|||
|
||||
|
||||
grainCable.celcius.forEach((celcius,i) => {
|
||||
|
||||
const nodeY = bottomY + (i * spacing);
|
||||
|
||||
const humidity = grainCable.relativeHumidity[i] || undefined;
|
||||
const moisture = grainCable.moisture[i] || undefined;
|
||||
let t = celcius
|
||||
//this is for testing to force a single node to a specific temp
|
||||
// if (i === 0) {
|
||||
// t = 30
|
||||
// }
|
||||
// if (i === 1) {
|
||||
// t = -30
|
||||
// }
|
||||
|
||||
|
||||
|
||||
nodeData.push({
|
||||
cableIndex: cableIndex,
|
||||
nodeIndex: i,
|
||||
radius: nodeRadius,
|
||||
position: new Vector3(cable.bottomPosition.x, nodeY, cable.bottomPosition.z),
|
||||
celcius: celcius,
|
||||
celcius: t,
|
||||
humidity: humidity,
|
||||
moisture: moisture,
|
||||
topNode: grainCable.topNode === i+1, //top node tracking starts at 1 not 0 so add one to the index
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import { Vector3 } from "three";
|
|||
import { useMemo } from "react";
|
||||
import { BuildCableData } from "../Data/BuildCableData";
|
||||
import { BuildNodeData } from "../Data/BuildNodeData";
|
||||
import Heatmap from "../Systems/Heatmap/HeatMapAlpha";
|
||||
import NodePointCloud from "../Systems/Heatmap/NodePointCloud";
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
|
|
@ -43,7 +45,7 @@ export default function Bin3dView(props: Props){
|
|||
radialSegments={20}
|
||||
binMetalness={0.5}
|
||||
binRoughness={0.7}
|
||||
binOpacity={0.5}
|
||||
binOpacity={0.2}
|
||||
diameter={bin.diameter()}
|
||||
sidewallHeight={bin.sidewallHeight()}
|
||||
roofHeight={bin.roofHeight()}
|
||||
|
|
@ -62,6 +64,7 @@ export default function Bin3dView(props: Props){
|
|||
)}
|
||||
{/* cables */}
|
||||
<BinCables cableData={cableData} nodeData={nodeData} bin={bin} binCenter={binCenter}/>
|
||||
<NodePointCloud bin={bin} nodes={nodeData} />
|
||||
</group>
|
||||
|
||||
{/* lighting */}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { pond } from "protobuf-ts/pond"
|
|||
import Ring, { RingGeometry } from "3dModels/Shapes/3D/Ring"
|
||||
// import { Color } from "three";
|
||||
import { NodeData } from "../../Data/BuildNodeData"
|
||||
import { tempToColour } from "bin/3dView/utils/tempToColour"
|
||||
import { TempToColour } from "bin/3dView/utils/tempToColour"
|
||||
|
||||
interface Props {
|
||||
node: NodeData
|
||||
|
|
@ -193,12 +193,26 @@ export default function CableNode(props: Props){
|
|||
return totalHeight / 2 - (index * ROW_HEIGHT) - ROW_HEIGHT / 2;
|
||||
};
|
||||
|
||||
const nodeColour = () => {
|
||||
if (node.excluded){
|
||||
return "grey"
|
||||
}
|
||||
if (!node.inGrain){
|
||||
return "white"
|
||||
}
|
||||
let c = TempToColour(node, lowerThreshold, upperThreshold, "node")
|
||||
if (c !== null){
|
||||
return c.getStyle()
|
||||
}
|
||||
return "white"
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{(!node.inGrain || !showLabel) &&
|
||||
<React.Fragment>
|
||||
<Sphere geometry={geometry} position={node.position} colour={tempToColour(node, lowerThreshold, upperThreshold, "node")?.getStyle()}/>
|
||||
<Sphere geometry={geometry} position={node.position} colour={nodeColour()}/>
|
||||
{node.topNode && (
|
||||
<Ring geometry={topNodeGeo} position={topNodePosition} rotation={topNodeRotation}/>
|
||||
)}
|
||||
|
|
|
|||
238
src/bin/3dView/Systems/Heatmap/HeatMapAlpha.tsx
Normal file
238
src/bin/3dView/Systems/Heatmap/HeatMapAlpha.tsx
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
import { NodeData } from "bin/3dView/Data/BuildNodeData";
|
||||
import { colourFade, TempToColour } from "bin/3dView/utils/tempToColour";
|
||||
import { Bin } from "models";
|
||||
import { pond } from "protobuf-ts/pond";
|
||||
import { useMemo } from "react";
|
||||
import { AdditiveBlending, Vector3 } from "three";
|
||||
|
||||
interface Props{
|
||||
bin: Bin
|
||||
nodes: NodeData[]
|
||||
}
|
||||
|
||||
/**
|
||||
* this is a work in progress, the heatmap generated may not be accurate so avoid using this component for now
|
||||
* @param props
|
||||
* @returns
|
||||
*/
|
||||
export default function Heatmap(props: Props){
|
||||
const {bin, nodes} = props
|
||||
//the steps that control how many 'layers' the heatmap will generate for each direction
|
||||
const radialSteps = 18; // number of points across the diameter of the pin
|
||||
const heightSteps = 20; // number of points up the side of the bin
|
||||
const angleSteps = 40; // number of points around the circumfrence of the bin
|
||||
|
||||
const getHeatIntensity = (
|
||||
temp: number,
|
||||
lower: number,
|
||||
upper: number,
|
||||
fade: number
|
||||
) => {
|
||||
if (temp >= lower && temp <= upper) return 0;
|
||||
|
||||
const distance =
|
||||
temp < lower ? lower - temp : temp - upper;
|
||||
|
||||
// clamp 0 → 1
|
||||
return Math.min(1, distance / fade);
|
||||
}
|
||||
|
||||
const getTemperatureAtPoint = (
|
||||
point: Vector3,
|
||||
nodes: NodeData[]
|
||||
) => {
|
||||
let totalWeight = 0;
|
||||
let weightedTemp = 0;
|
||||
|
||||
//not sure if we should use a hard coded value or use a percentage of the bins diameter
|
||||
//const maxDistance = 450; //tweak this (cm), it is the max distance that a node can influence the heatmap points
|
||||
const maxDistance = bin.diameter() * 0.25;
|
||||
|
||||
nodes.forEach(node => {
|
||||
if (!node.inGrain || node.excluded) return;
|
||||
|
||||
const distance = point.distanceTo(node.position);
|
||||
|
||||
const intensity = getHeatIntensity(
|
||||
node.celcius,
|
||||
bin.lowerTempThreshold(),
|
||||
bin.upperTempThreshold(),
|
||||
colourFade
|
||||
);
|
||||
|
||||
const nodeMaxDistance = maxDistance * (0.5 + intensity);
|
||||
// tweakable: 0.5–1.5 range
|
||||
|
||||
if (distance > nodeMaxDistance) return;
|
||||
|
||||
const weight = 1 / (distance * distance + 1);
|
||||
|
||||
totalWeight += weight;
|
||||
weightedTemp += node.celcius * weight;
|
||||
});
|
||||
|
||||
if (totalWeight === 0) return null;
|
||||
|
||||
return weightedTemp / totalWeight;
|
||||
}
|
||||
|
||||
//this gets the highest top node to prevent points from being rendered above it
|
||||
//we could in the future us the multple top nodes to clamp within a cloumn around that cable which would give us a more realistic grain area
|
||||
const maxGrainY = useMemo(() => {
|
||||
let maxY = -Infinity;
|
||||
|
||||
nodes.forEach(node => {
|
||||
if (!node.inGrain || node.excluded) return;
|
||||
|
||||
if (node.position.y > maxY) {
|
||||
maxY = node.position.y;
|
||||
}
|
||||
});
|
||||
|
||||
return maxY;
|
||||
}, [nodes]);
|
||||
|
||||
const samplePoints = useMemo(() => {
|
||||
const points = [];
|
||||
|
||||
const radius = bin.diameter() / 2;
|
||||
const height = bin.sidewallHeight();
|
||||
|
||||
|
||||
|
||||
for (let yStep = 0; yStep < heightSteps; yStep++) {
|
||||
const y = -height / 2 + (yStep / heightSteps) * height;
|
||||
if (y > maxGrainY) continue;
|
||||
|
||||
for (let rStep = 0; rStep < radialSteps; rStep++) {
|
||||
// const r = (rStep / radialSteps) * radius;
|
||||
const r = Math.sqrt(rStep / radialSteps) * radius;
|
||||
|
||||
for (let aStep = 0; aStep < angleSteps; aStep++) {
|
||||
const angle = (aStep / angleSteps) * Math.PI * 2;
|
||||
|
||||
const x = Math.cos(angle) * r;
|
||||
const z = Math.sin(angle) * r;
|
||||
|
||||
const position = new Vector3(x, y, z);
|
||||
|
||||
const temp = getTemperatureAtPoint(position, nodes);
|
||||
|
||||
points.push({
|
||||
position,
|
||||
temp
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return points;
|
||||
}, [bin, nodes]);
|
||||
|
||||
const { positions, colors, alphas } = useMemo(() => {
|
||||
const positions: number[] = [];
|
||||
const colors: number[] = [];
|
||||
const alphas: number[] = [];
|
||||
|
||||
samplePoints.forEach((p) => {
|
||||
if (p.temp === null) return;
|
||||
const intensity = getHeatIntensity(
|
||||
p.temp,
|
||||
bin.lowerTempThreshold(),
|
||||
bin.upperTempThreshold(),
|
||||
colourFade
|
||||
);
|
||||
const alpha = Math.pow(intensity, 2); // try 2 → 3 for stronger fade
|
||||
|
||||
const virtualNode: NodeData = {
|
||||
cableIndex: -1,
|
||||
nodeIndex: -1,
|
||||
radius: 0,
|
||||
position: p.position,
|
||||
celcius: p.temp,
|
||||
inGrain: true,
|
||||
excluded: false
|
||||
};
|
||||
|
||||
const color = TempToColour(
|
||||
virtualNode,
|
||||
bin.lowerTempThreshold(),
|
||||
bin.upperTempThreshold(),
|
||||
"heatmap"
|
||||
);
|
||||
|
||||
if (!color) return;
|
||||
|
||||
// position
|
||||
positions.push(p.position.x, p.position.y, p.position.z);
|
||||
|
||||
// color (normalized 0–1)
|
||||
colors.push(color.r, color.g, color.b);
|
||||
alphas.push(alpha)
|
||||
|
||||
});
|
||||
|
||||
return {
|
||||
positions: new Float32Array(positions),
|
||||
colors: new Float32Array(colors),
|
||||
alphas: new Float32Array(alphas)
|
||||
};
|
||||
}, [samplePoints, bin]);
|
||||
|
||||
return (
|
||||
<points>
|
||||
<bufferGeometry>
|
||||
<bufferAttribute
|
||||
attach="attributes-position"
|
||||
array={positions}
|
||||
count={positions.length / 3}
|
||||
itemSize={3}
|
||||
/>
|
||||
<bufferAttribute
|
||||
attach="attributes-color"
|
||||
array={colors}
|
||||
count={colors.length / 3}
|
||||
itemSize={3}
|
||||
/>
|
||||
<bufferAttribute
|
||||
attach="attributes-alpha"
|
||||
array={alphas}
|
||||
itemSize={1}
|
||||
/>
|
||||
</bufferGeometry>
|
||||
|
||||
<shaderMaterial
|
||||
transparent
|
||||
depthWrite={false}
|
||||
blending={AdditiveBlending}
|
||||
vertexColors
|
||||
uniforms={{ pointSize: { value: 0.7 } }}
|
||||
vertexShader={`
|
||||
attribute float alpha;
|
||||
varying float vAlpha;
|
||||
varying vec3 vColor;
|
||||
|
||||
void main() {
|
||||
vAlpha = alpha;
|
||||
vColor = color;
|
||||
|
||||
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
|
||||
gl_PointSize = 10.0; // tweak this instead of size prop
|
||||
gl_Position = projectionMatrix * mvPosition;
|
||||
}
|
||||
`}
|
||||
fragmentShader={`
|
||||
varying float vAlpha;
|
||||
varying vec3 vColor;
|
||||
|
||||
void main() {
|
||||
float dist = length(gl_PointCoord - vec2(0.5));
|
||||
float falloff = smoothstep(0.5, 0.0, dist);
|
||||
|
||||
gl_FragColor = vec4(vColor, vAlpha * falloff);
|
||||
}
|
||||
`}
|
||||
/>
|
||||
</points>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
import { NodeData } from "bin/3dView/Data/BuildNodeData";
|
||||
import { Bin } from "models";
|
||||
|
||||
interface Props{
|
||||
bin: Bin
|
||||
nodes: NodeData
|
||||
}
|
||||
|
||||
export default function Heatmap(props: Props){
|
||||
const {bin, nodes} = props
|
||||
|
||||
return (
|
||||
<>
|
||||
</>
|
||||
)
|
||||
}
|
||||
215
src/bin/3dView/Systems/Heatmap/NodePointCloud.tsx
Normal file
215
src/bin/3dView/Systems/Heatmap/NodePointCloud.tsx
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
import { NodeData } from "bin/3dView/Data/BuildNodeData";
|
||||
import { colourFade } from "bin/3dView/utils/tempToColour";
|
||||
import { Bin } from "models";
|
||||
import { useMemo } from "react";
|
||||
import { AdditiveBlending, CanvasTexture } from "three";
|
||||
|
||||
interface Props {
|
||||
bin: Bin;
|
||||
nodes: NodeData[];
|
||||
}
|
||||
|
||||
export default function NodePointCloud(props: Props) {
|
||||
const { bin, nodes } = props;
|
||||
// 🎛️ TUNING KNOBS
|
||||
const BASE_RADIUS_FACTOR = 0.15;
|
||||
const INTENSITY_DENSITY_MULT = 1.25;
|
||||
const INTENSITY_DENSITY_BASE = 0.3;
|
||||
|
||||
const RADIAL_BASE = 6;
|
||||
const THETA_BASE = 10;
|
||||
const PHI_BASE = 10;
|
||||
|
||||
const MIN_EDGE_INTENSITY = 0.2;
|
||||
|
||||
const BASE_BRIGHTNESS = 0.2;
|
||||
const INTENSITY_BRIGHTNESS_MULT = 0.5;
|
||||
|
||||
const OPACITY = 0.7;
|
||||
const POINT_SIZE = 1;
|
||||
|
||||
const getHeatIntensity = (
|
||||
temp: number,
|
||||
lower: number,
|
||||
upper: number,
|
||||
fade: number
|
||||
) => {
|
||||
if (temp >= lower && temp <= upper) return 0;
|
||||
|
||||
const distance =
|
||||
temp < lower ? lower - temp : temp - upper;
|
||||
|
||||
return Math.min(1, distance / fade);
|
||||
};
|
||||
|
||||
// -----------------------------
|
||||
// GRAIN LIMIT
|
||||
// -----------------------------
|
||||
const maxGrainY = useMemo(() => {
|
||||
let maxY = -Infinity;
|
||||
|
||||
nodes.forEach((node) => {
|
||||
if (!node.inGrain || node.excluded) return;
|
||||
if (node.position.y > maxY) maxY = node.position.y;
|
||||
});
|
||||
|
||||
return maxY;
|
||||
}, [nodes]);
|
||||
|
||||
// -----------------------------
|
||||
// BUILD POINT CLOUD FROM NODES
|
||||
// -----------------------------
|
||||
const { positions, colors } = useMemo(() => {
|
||||
const positions: number[] = [];
|
||||
const colors: number[] = [];
|
||||
|
||||
const baseRadius = bin.diameter() * BASE_RADIUS_FACTOR;
|
||||
|
||||
nodes.forEach((node) => {
|
||||
if (!node.inGrain || node.excluded) return;
|
||||
|
||||
const intensity = getHeatIntensity(
|
||||
node.celcius,
|
||||
bin.lowerTempThreshold(),
|
||||
bin.upperTempThreshold(),
|
||||
colourFade
|
||||
);
|
||||
|
||||
// skip normal nodes
|
||||
if (intensity === 0) return;
|
||||
|
||||
const isHot = node.celcius > bin.upperTempThreshold();
|
||||
|
||||
// expand cloud based on severity
|
||||
const radius = baseRadius * (0.5 + intensity);
|
||||
|
||||
//these control the number of points along that axis
|
||||
const densityMultiplier = INTENSITY_DENSITY_BASE + intensity * INTENSITY_DENSITY_MULT;
|
||||
const radialSteps = Math.floor(RADIAL_BASE * densityMultiplier);//points along the radius to the edge
|
||||
const thetaSteps = Math.floor(THETA_BASE * densityMultiplier);//points along the azimuth (horizontal angle)
|
||||
const phiSteps = Math.floor(PHI_BASE * densityMultiplier);//points along the vertical angle
|
||||
|
||||
const radiusLimit = bin.diameter() / 2;
|
||||
const minY = -bin.sidewallHeight() / 2;
|
||||
|
||||
for (let rStep = 0; rStep < radialSteps; rStep++) {
|
||||
// sqrt for even density
|
||||
const r = Math.sqrt(rStep / radialSteps) * radius;
|
||||
|
||||
for (let pStep = 0; pStep < phiSteps; pStep++) {
|
||||
// avoid poles clustering by not hitting exact 0/PI
|
||||
const phi =
|
||||
((pStep + 0.5) / phiSteps) * Math.PI;
|
||||
|
||||
for (let tStep = 0; tStep < thetaSteps; tStep++) {
|
||||
const theta =
|
||||
(tStep / thetaSteps) * Math.PI * 2;
|
||||
|
||||
const x =
|
||||
node.position.x +
|
||||
r * Math.sin(phi) * Math.cos(theta);
|
||||
|
||||
const y =
|
||||
node.position.y +
|
||||
r * Math.cos(phi);
|
||||
|
||||
const z =
|
||||
node.position.z +
|
||||
r * Math.sin(phi) * Math.sin(theta);
|
||||
|
||||
// cylindrical bin bounds
|
||||
const distXZ = Math.sqrt(x * x + z * z);
|
||||
if (distXZ > radiusLimit) continue;
|
||||
if (y > maxGrainY || y < minY) continue;
|
||||
|
||||
positions.push(x, y, z);
|
||||
|
||||
const falloff = 1 - r / radius;
|
||||
|
||||
// shape of the curve
|
||||
const spatialFalloff = Math.pow(falloff, 2);
|
||||
|
||||
const adjustedFalloff =
|
||||
MIN_EDGE_INTENSITY + (1 - MIN_EDGE_INTENSITY) * spatialFalloff;
|
||||
|
||||
const intensityCurve = intensity * intensity; // quadratic easing
|
||||
const baseBrightness =
|
||||
BASE_BRIGHTNESS + INTENSITY_BRIGHTNESS_MULT * intensityCurve;
|
||||
|
||||
const brightness = adjustedFalloff * baseBrightness;
|
||||
|
||||
if (isHot) {
|
||||
colors.push(brightness, 0, 0);
|
||||
} else {
|
||||
colors.push(0, 0, brightness);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
positions: new Float32Array(positions),
|
||||
colors: new Float32Array(colors),
|
||||
};
|
||||
}, [nodes, bin, maxGrainY]);
|
||||
|
||||
const circleTexture = useMemo(() => {
|
||||
const size = 64;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
const gradient = ctx.createRadialGradient(
|
||||
size / 2,
|
||||
size / 2,
|
||||
0,
|
||||
size / 2,
|
||||
size / 2,
|
||||
size / 2
|
||||
);
|
||||
|
||||
gradient.addColorStop(0, "rgba(255,255,255,1)");
|
||||
gradient.addColorStop(1, "rgba(255,255,255,0)");
|
||||
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
|
||||
const texture = new CanvasTexture(canvas);
|
||||
texture.needsUpdate = true;
|
||||
return texture;
|
||||
}, []);
|
||||
|
||||
// -----------------------------
|
||||
// RENDER
|
||||
// -----------------------------
|
||||
return (
|
||||
<points>
|
||||
<bufferGeometry>
|
||||
<bufferAttribute
|
||||
attach="attributes-position"
|
||||
array={positions}
|
||||
count={positions.length / 3}
|
||||
itemSize={3}
|
||||
/>
|
||||
<bufferAttribute
|
||||
attach="attributes-color"
|
||||
array={colors}
|
||||
count={colors.length / 3}
|
||||
itemSize={3}
|
||||
/>
|
||||
</bufferGeometry>
|
||||
|
||||
<pointsMaterial
|
||||
size={POINT_SIZE}
|
||||
map={circleTexture}
|
||||
vertexColors
|
||||
transparent
|
||||
opacity={OPACITY}
|
||||
depthWrite={false}
|
||||
blending={AdditiveBlending}
|
||||
/>
|
||||
</points>
|
||||
);
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@ const GREEN = new Color("#52c41a");
|
|||
const BLUE = new Color("#3399ff");
|
||||
const RED = new Color("#ff4d4f");
|
||||
|
||||
const colourFade = 20;
|
||||
export const colourFade = 20;
|
||||
const minimumLightness = 0.3;
|
||||
const lightnessRange = 0.2;
|
||||
const minimumSaturation = 0.7;
|
||||
|
|
@ -30,7 +30,7 @@ const getVisualIntensity = (distance: number, mode: ColourMode) => {
|
|||
}
|
||||
};
|
||||
|
||||
export function tempToColour(
|
||||
export function TempToColour(
|
||||
node: NodeData,
|
||||
lower: number,
|
||||
upper: number,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue