[verified] feat: add diagram toolbox capabilities
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
import type { DiagramIRV2 } from "../../model/diagram-ir.js";
|
||||
|
||||
export interface HeatmapRequest {
|
||||
metric: string;
|
||||
min: number;
|
||||
max: number;
|
||||
values: Readonly<Record<string, number>>;
|
||||
colors?: readonly string[];
|
||||
}
|
||||
export interface HeatmapLegendStop { value: number; color: string; fontColor: string }
|
||||
export interface HeatmapLegend { metric: string; min: number; max: number; stops: HeatmapLegendStop[] }
|
||||
export interface HeatmapResult { diagram: DiagramIRV2; legend: HeatmapLegend }
|
||||
|
||||
export const DEFAULT_HEATMAP_COLORS = Object.freeze(["#440154", "#3B528B", "#21918C", "#5EC962", "#FDE725"]);
|
||||
const HEX_COLOR = /^#[0-9A-Fa-f]{6}$/;
|
||||
|
||||
function setHeatmapColors(style: string | undefined, color: string, fontColor: string): string {
|
||||
const entries = (style ?? "").split(";").filter(Boolean);
|
||||
const replacements = new Map([["fillColor", color], ["fontColor", fontColor]]);
|
||||
const targetKeys = new Set(replacements.keys());
|
||||
const output = entries.map((entry) => {
|
||||
const key = entry.split("=", 1)[0];
|
||||
if (!targetKeys.has(key)) return entry;
|
||||
const replacement = replacements.get(key);
|
||||
if (replacement === undefined) return undefined;
|
||||
replacements.delete(key);
|
||||
return `${key}=${replacement}`;
|
||||
}).filter((entry): entry is string => entry !== undefined);
|
||||
for (const [key, value] of replacements) output.push(`${key}=${value}`);
|
||||
return `${output.join(";")};`;
|
||||
}
|
||||
|
||||
function contrastRatio(left: string, right: string): number {
|
||||
const luminance = (color: string): number => [1, 3, 5]
|
||||
.map((offset) => Number.parseInt(color.slice(offset, offset + 2), 16) / 255)
|
||||
.map((value) => value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4)
|
||||
.reduce((total, value, index) => total + value * [0.2126, 0.7152, 0.0722][index], 0);
|
||||
const [high, low] = [luminance(left), luminance(right)].sort((a, b) => b - a);
|
||||
return (high + 0.05) / (low + 0.05);
|
||||
}
|
||||
|
||||
const READABLE_FONT_COLORS = ["#FFFFFF", "#1A1A1A"] as const;
|
||||
|
||||
function readableFontColor(background: string): string {
|
||||
const color = READABLE_FONT_COLORS.find((candidate) => contrastRatio(candidate, background) >= 4.5);
|
||||
if (!color) throw new Error(`Heatmap palette color ${background} has no readable font color`);
|
||||
return color;
|
||||
}
|
||||
|
||||
function exactNumber(value: number): number {
|
||||
return Number(value.toFixed(12));
|
||||
}
|
||||
|
||||
export function applyMetricsHeatmap(ir: DiagramIRV2, request: HeatmapRequest): HeatmapResult {
|
||||
if (typeof request.metric !== "string" || request.metric.trim() === "") throw new Error("Heatmap metric must be a non-empty string");
|
||||
if (!Number.isFinite(request.min) || !Number.isFinite(request.max) || request.min >= request.max) throw new Error("Heatmap min must be less than max");
|
||||
if (!request.values || typeof request.values !== "object" || Array.isArray(request.values)) throw new Error("Heatmap values must be an object");
|
||||
const colors = [...(request.colors ?? DEFAULT_HEATMAP_COLORS)];
|
||||
if (colors.length < 2 || colors.some((color) => typeof color !== "string" || !HEX_COLOR.test(color))) throw new Error("Heatmap colors must contain at least two #RRGGBB colors");
|
||||
const nodeKeys = new Set(ir.pages.flatMap((page) => page.nodes.map((node) => `${page.id}/${node.id}`)));
|
||||
for (const [key, value] of Object.entries(request.values)) {
|
||||
if (!nodeKeys.has(key)) throw new Error(`Heatmap value has unknown node: ${key}`);
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < request.min || value > request.max) {
|
||||
throw new Error(`Heatmap value for ${key} must be numeric and bounded by min and max`);
|
||||
}
|
||||
}
|
||||
const stops = colors.map((color, index) => ({
|
||||
value: exactNumber(request.min + (request.max - request.min) * index / (colors.length - 1)),
|
||||
color,
|
||||
fontColor: readableFontColor(color),
|
||||
}));
|
||||
const legend: HeatmapLegend = { metric: request.metric, min: request.min, max: request.max, stops };
|
||||
const diagram = structuredClone(ir);
|
||||
for (const page of diagram.pages) for (const node of page.nodes) {
|
||||
const value = request.values[`${page.id}/${node.id}`];
|
||||
if (value === undefined) continue;
|
||||
const nextStopIndex = stops.findIndex((stop) => value < stop.value);
|
||||
const colorIndex = nextStopIndex === -1 ? stops.length - 1 : Math.max(0, nextStopIndex - 1);
|
||||
node.style = setHeatmapColors(node.style, colors[colorIndex], stops[colorIndex].fontColor);
|
||||
}
|
||||
diagram.extensions = { ...(diagram.extensions ?? {}), heatmap: legend };
|
||||
return { diagram, legend };
|
||||
}
|
||||
|
||||
export const transformMetricsHeatmap = applyMetricsHeatmap;
|
||||
Reference in New Issue
Block a user