///
import { type Cell, Default, handler, lift, recipe, str } from "commontools";
interface HeatmapAggregationArgs {
width: Default;
height: Default;
interactions: Default;
}
interface HeatmapInteractionInput {
x?: number;
y?: number;
weight?: number;
}
interface HeatmapBucket {
x: number;
y: number;
weight: number;
}
interface HeatmapPeak {
x: number;
y: number;
intensity: number;
}
interface RecordInteractionEvent {
x?: number;
y?: number;
weight?: number;
}
interface RecordInteractionBatchEvent {
points?: RecordInteractionEvent[];
}
type InteractionEvent = RecordInteractionEvent | RecordInteractionBatchEvent;
const sanitizeDimension = (value: unknown, fallback: number): number => {
if (typeof value !== "number" || !Number.isFinite(value)) {
return fallback;
}
const rounded = Math.floor(value);
if (rounded < 1) return fallback;
if (rounded > 50) return 50;
return rounded;
};
const sanitizeWeight = (value: unknown): number => {
if (typeof value !== "number" || !Number.isFinite(value)) return 1;
const sanitized = Math.max(0, value);
return Math.round(sanitized * 100) / 100;
};
const clampCoordinate = (value: unknown, maxIndex: number): number => {
if (typeof value !== "number" || !Number.isFinite(value)) return 0;
const floored = Math.floor(value);
if (floored < 0) return 0;
if (floored > maxIndex) return maxIndex;
return floored;
};
const sanitizeInteractions = (
value: unknown,
width: number,
height: number,
): HeatmapBucket[] => {
if (!Array.isArray(value)) return [];
const maxX = width > 0 ? width - 1 : 0;
const maxY = height > 0 ? height - 1 : 0;
const sanitized: HeatmapBucket[] = [];
for (const item of value) {
const record = item as HeatmapInteractionInput | undefined;
const x = clampCoordinate(record?.x, maxX);
const y = clampCoordinate(record?.y, maxY);
const weight = sanitizeWeight(record?.weight);
sanitized.push({ x, y, weight });
}
return sanitized;
};
const buildGrid = (
width: number,
height: number,
buckets: readonly HeatmapBucket[],
): number[][] => {
const rows = Array.from(
{ length: height },
() => Array.from({ length: width }, () => 0),
);
for (const bucket of buckets) {
const row = rows[bucket.y] ?? rows[rows.length - 1];
if (!row) continue;
const current = row[bucket.x] ?? 0;
const next = Math.round((current + bucket.weight) * 100) / 100;
row[bucket.x] = next;
}
return rows;
};
const findMaxIntensity = (grid: readonly (readonly number[])[]): number => {
let max = 0;
for (const row of grid) {
for (const value of row) {
if (typeof value !== "number" || !Number.isFinite(value)) continue;
if (value > max) max = value;
}
}
return Math.round(max * 100) / 100;
};
const normalizeGrid = (
grid: readonly (readonly number[])[],
maxIntensity: number,
): number[][] => {
if (maxIntensity <= 0) {
return grid.map((row) => row.map(() => 0));
}
const factor = 1 / maxIntensity;
return grid.map((row) =>
row.map((value) => {
if (typeof value !== "number" || !Number.isFinite(value)) return 0;
const normalized = value * factor;
return Math.round(normalized * 100) / 100;
})
);
};
const locatePeaks = (
grid: readonly (readonly number[])[],
maxIntensity: number,
): HeatmapPeak[] => {
if (maxIntensity <= 0) return [];
const peaks: HeatmapPeak[] = [];
for (let y = 0; y < grid.length; y++) {
const row = grid[y];
for (let x = 0; x < row.length; x++) {
const value = row[x];
if (value === maxIntensity) {
peaks.push({ x, y, intensity: value });
}
}
}
return peaks;
};
const describePeaks = (peaks: readonly HeatmapPeak[]): string => {
if (peaks.length === 0) return "no hotspots";
if (peaks.length === 1) {
const [peak] = peaks;
return `(${peak.x},${peak.y})`;
}
return peaks
.map((peak) => `(${peak.x},${peak.y})`)
.join(" • ");
};
const recordInteraction = handler(
(
event: InteractionEvent | undefined,
context: {
interactions: Cell;
width: Cell;
height: Cell;
},
) => {
const width = sanitizeDimension(context.width.get(), 4);
const height = sanitizeDimension(context.height.get(), 3);
if (context.width.get() !== width) context.width.set(width);
if (context.height.get() !== height) context.height.set(height);
const current = context.interactions.get();
const list = Array.isArray(current) ? current.slice() : [];
const applyPoint = (point: RecordInteractionEvent | undefined) => {
if (!point) return;
const [bucket] = sanitizeInteractions([point], width, height);
if (!bucket) return;
list.push({ x: bucket.x, y: bucket.y, weight: bucket.weight });
};
if (
Array.isArray((event as RecordInteractionBatchEvent | undefined)?.points)
) {
for (const item of (event as RecordInteractionBatchEvent).points ?? []) {
applyPoint(item);
}
} else {
applyPoint(event as RecordInteractionEvent | undefined);
}
context.interactions.set(list);
},
);
const updateDimensions = handler(
(
event: { width?: number; height?: number } | undefined,
context: { width: Cell; height: Cell },
) => {
const currentWidth = sanitizeDimension(context.width.get(), 4);
const currentHeight = sanitizeDimension(context.height.get(), 3);
const nextWidth = sanitizeDimension(event?.width, currentWidth);
const nextHeight = sanitizeDimension(event?.height, currentHeight);
if (nextWidth !== currentWidth) context.width.set(nextWidth);
if (nextHeight !== currentHeight) context.height.set(nextHeight);
},
);
export const heatmapAggregation = recipe(
"Heatmap Aggregation",
({ width, height, interactions }) => {
const sanitizedWidth = lift((value: number | undefined) =>
sanitizeDimension(value, 4)
)(width);
const sanitizedHeight = lift((value: number | undefined) =>
sanitizeDimension(value, 3)
)(height);
const buckets = lift(
(
inputs: {
source: HeatmapInteractionInput[];
width: number;
height: number;
},
): HeatmapBucket[] =>
sanitizeInteractions(
inputs.source,
inputs.width,
inputs.height,
),
)({ source: interactions, width: sanitizedWidth, height: sanitizedHeight });
const bucketGrid = lift(
(
inputs: { width: number; height: number; buckets: HeatmapBucket[] },
): number[][] => buildGrid(inputs.width, inputs.height, inputs.buckets),
)({ buckets, width: sanitizedWidth, height: sanitizedHeight });
const maxIntensity = lift(findMaxIntensity)(bucketGrid);
const normalizedGrid = lift(
(inputs: { grid: number[][]; max: number }) =>
normalizeGrid(inputs.grid, inputs.max),
)({ grid: bucketGrid, max: maxIntensity });
const peaks = lift(
(inputs: { grid: number[][]; max: number }) =>
locatePeaks(inputs.grid, inputs.max),
)({ grid: bucketGrid, max: maxIntensity });
const interactionCount = lift((items: HeatmapBucket[]) => {
const total = items.reduce((sum, item) => sum + item.weight, 0);
return Math.round(total * 100) / 100;
})(buckets);
const peakSummary = lift(describePeaks)(peaks);
const label = str`Peak intensity ${maxIntensity} at ${peakSummary}`;
return {
width,
height,
interactions,
sanitizedWidth,
sanitizedHeight,
buckets,
bucketGrid,
normalizedGrid,
maxIntensity,
peaks,
interactionCount,
peakSummary,
label,
record: recordInteraction({ interactions, width, height }),
resize: updateDimensions({ width, height }),
};
},
);