website_next: part 5

This commit is contained in:
nym21
2026-07-06 15:54:25 +02:00
parent 3f2a48f248
commit 2207ec1b7e
25 changed files with 1083 additions and 130 deletions
+59
View File
@@ -0,0 +1,59 @@
import { packCells } from "./pack.js";
/**
* @param {number} weight
* @param {number} capacity
* @param {number} columns
* @param {"floor" | "round"} rounding
*/
function weightToSpan(weight, capacity, columns, rounding) {
const cellWeight = capacity / (columns * columns);
const span = Math.sqrt(weight / cellWeight);
return Math.max(1, Math[rounding](span));
}
/**
* @template {CapacityCell} Cell
* @param {readonly Cell[]} cells
* @param {number} capacity
* @param {number} columns
* @param {"floor" | "round"} rounding
*/
function resolveCapacityCells(cells, capacity, columns, rounding) {
return cells.map((cell) => ({
...cell,
span: weightToSpan(cell.weight ?? 0, capacity, columns, rounding),
}));
}
/**
* @template {CapacityCell} Cell
* @param {readonly Cell[]} cells
* @param {number} capacity
* @param {number} columns
* @param {"floor" | "round"} rounding
*/
function createSquareLayout(cells, capacity, columns, rounding) {
const resolvedCells = resolveCapacityCells(cells, capacity, columns, rounding);
const layouts = packCells(resolvedCells, columns, columns);
return layouts === null ? null : { columns, resolvedCells, layouts };
}
/**
* @template {CapacityCell} Cell
* @param {readonly Cell[]} cells
* @param {number} capacity
* @param {number} columns
*/
export function findSquareLayout(cells, capacity, columns) {
return createSquareLayout(cells, capacity, columns, "round") ??
createSquareLayout(cells, capacity, columns, "floor");
}
/**
* @typedef {Object} CapacityCell
* @property {number} span
* @property {number | undefined} weight
*/
+160
View File
@@ -0,0 +1,160 @@
import { findSquareLayout } from "./capacity.js";
import { packCells } from "./pack.js";
/**
* @param {HeatmapItem} item
* @returns {HTMLSpanElement}
*/
function createCell(item) {
const cell = document.createElement("span");
cell.dataset.heatmapCell = item.group;
cell.style.setProperty("--color", item.color);
cell.title = item.title;
return cell;
}
/**
* @param {HTMLElement} parent
* @param {string} width
*/
function createMeasure(parent, width) {
const measure = document.createElement("i");
measure.dataset.heatmapMeasure = "";
measure.style.setProperty("width", width);
parent.append(measure);
return measure;
}
/**
* @param {number} count
* @param {number} cell
* @param {number} gap
*/
function unitsToPixels(count, cell, gap) {
return count * cell + Math.max(0, count - 1) * gap;
}
/**
* @param {HTMLElement} map
* @param {HTMLElement} cellMeasure
* @param {HTMLElement} gapMeasure
* @param {readonly HeatmapCell[]} cells
* @param {HeatmapOptions} options
*/
function layoutHeatmap(map, cellMeasure, gapMeasure, cells, options) {
const baseCell = cellMeasure.getBoundingClientRect().width;
const gap = gapMeasure.getBoundingClientRect().width - baseCell;
const width = map.getBoundingClientRect().width;
let cell = baseCell;
let columns = Math.max(1, Math.floor((width + gap) / (baseCell + gap)));
let resolvedCells = cells;
let layouts = packCells(resolvedCells, columns);
if (options.shape === "square" && options.capacity !== undefined) {
const square = findSquareLayout(
cells,
options.capacity,
options.columns ?? columns,
);
if (square === null) return;
columns = square.columns;
resolvedCells = square.resolvedCells;
layouts = square.layouts;
cell = Math.max(1, Math.floor((width - gap * (columns - 1)) / columns));
}
if (layouts === null) return;
const rows = Math.max(...layouts.map(({ rows }) => rows), 0);
const gridWidth = unitsToPixels(columns, cell, gap);
const offset = Math.max(0, (width - gridWidth) / 2);
map.style.setProperty("height", `${unitsToPixels(rows, cell, gap)}px`);
layouts.forEach((layout, index) => {
const node = resolvedCells[index].node;
node.style.setProperty("--x", `${offset + layout.x * (cell + gap)}px`);
node.style.setProperty("--y", `${layout.y * (cell + gap)}px`);
node.style.setProperty("--size", `${unitsToPixels(layout.span, cell, gap)}px`);
});
}
/**
* @param {HTMLElement} map
* @param {HTMLElement} cellMeasure
* @param {HTMLElement} gapMeasure
* @param {readonly HeatmapCell[]} cells
* @param {HeatmapOptions} options
*/
function observeLayout(map, cellMeasure, gapMeasure, cells, options) {
let frame = 0;
const layout = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
layoutHeatmap(map, cellMeasure, gapMeasure, cells, options);
});
};
const observer = new ResizeObserver(layout);
observer.observe(map);
map.addEventListener("heatmap:destroy", () => {
cancelAnimationFrame(frame);
observer.disconnect();
}, { once: true });
layout();
}
/**
* @param {HeatmapItem[]} items
* @param {HeatmapOptions} [options]
*/
export function createHeatmap(items, options = {}) {
const map = document.createElement("div");
const cells = items.map((item) => ({
node: createCell(item),
span: Math.max(1, Math.round(item.span ?? 1)),
weight: item.weight,
}));
map.dataset.heatmap = options.origin ?? "";
map.append(...cells.map(({ node }) => node));
observeLayout(
map,
createMeasure(map, "var(--heatmap-cell-size)"),
createMeasure(map, "calc(var(--heatmap-cell-size) + var(--heatmap-gap))"),
cells,
options,
);
return map;
}
/**
* @typedef {Object} HeatmapItem
* @property {string} color
* @property {string} group
* @property {string} title
* @property {number} [span]
* @property {number} [weight]
*/
/**
* @typedef {Object} HeatmapOptions
* @property {"bottom"} [origin]
* @property {"square"} [shape]
* @property {number} [capacity]
* @property {number} [columns]
*/
/**
* @typedef {Object} HeatmapCell
* @property {HTMLSpanElement} node
* @property {number} span
* @property {number | undefined} weight
*/
+89
View File
@@ -0,0 +1,89 @@
/**
* @param {readonly PackCell[]} cells
* @param {number} columns
* @param {number} [rows]
* @returns {PackLayout[] | null}
*/
export function packCells(cells, columns, rows) {
const occupied = /** @type {boolean[][]} */ ([]);
const layouts = [];
let usedRows = 0;
for (const cell of cells) {
const span = Math.min(cell.span, columns);
const position = findPosition(occupied, columns, span, rows);
if (position === null) return null;
fillCells(occupied, position.x, position.y, span);
usedRows = Math.max(usedRows, position.y + span);
layouts.push({ ...position, span, rows: rows ?? usedRows });
}
return layouts;
}
/**
* @param {boolean[][]} occupied
* @param {number} columns
* @param {number} span
* @param {number} [rows]
* @returns {{ x: number, y: number } | null}
*/
function findPosition(occupied, columns, span, rows) {
const lastRow = rows === undefined ? Infinity : rows - span;
for (let y = 0; y <= lastRow; y += 1) {
for (let x = 0; x <= columns - span; x += 1) {
if (canPlace(occupied, x, y, span)) return { x, y };
}
}
return null;
}
/**
* @param {boolean[][]} occupied
* @param {number} x
* @param {number} y
* @param {number} span
*/
function canPlace(occupied, x, y, span) {
for (let row = y; row < y + span; row += 1) {
for (let column = x; column < x + span; column += 1) {
if (occupied[row]?.[column]) return false;
}
}
return true;
}
/**
* @param {boolean[][]} occupied
* @param {number} x
* @param {number} y
* @param {number} span
*/
function fillCells(occupied, x, y, span) {
for (let row = y; row < y + span; row += 1) {
occupied[row] ??= [];
for (let column = x; column < x + span; column += 1) {
occupied[row][column] = true;
}
}
}
/**
* @typedef {Object} PackCell
* @property {number} span
*/
/**
* @typedef {Object} PackLayout
* @property {number} x
* @property {number} y
* @property {number} span
* @property {number} rows
*/
+30
View File
@@ -0,0 +1,30 @@
[data-heatmap] {
--heatmap-cell-size: 0.4rem;
--heatmap-gap: 2px;
position: relative;
min-width: 0;
}
[data-heatmap-cell] {
position: absolute;
left: var(--x);
width: var(--size);
height: var(--size);
background: var(--color);
}
[data-heatmap="bottom"] > [data-heatmap-cell] {
bottom: var(--y);
}
[data-heatmap=""] > [data-heatmap-cell] {
top: var(--y);
}
[data-heatmap-measure] {
position: absolute;
height: 0;
visibility: hidden;
pointer-events: none;
}