Files

169 lines
5.5 KiB
JavaScript

// Pure helpers for the map view: geometry, path timing and colour blending.
// Nothing here touches the DOM or the MapView instance, so it is all trivially
// testable.
import { HEX_W, HEX_H } from "../../../shared/hex.js";
// Order-independent FNV-style hash of an [x, y, ...] entry array, used to skip
// rebuilding a layer when the snapshot did not actually change it.
export function hashEntries(entries) {
let hash = 2166136261;
for (const entry of entries) {
hash = Math.imul(hash ^ (entry[0] + 4096), 16777619);
hash = Math.imul(hash ^ (entry[1] + 4096), 16777619);
hash = Math.imul(hash ^ (entry.length > 2 ? entry[2] + 8 : 0), 16777619);
}
return `${entries.length}:${(hash >>> 0).toString(36)}`;
}
// How far along its path a unit is, in segment-hours, from a snapshot.
export function hoursAlong(data, segmentHours) {
const index = Math.min(data.pathIndex || 0, segmentHours.length);
let total = 0;
for (let i = 0; i < index; i++) total += segmentHours[i];
return total + (data.progressHours || 0);
}
export function samePath(a, b) {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i].x !== b[i].x || a[i].y !== b[i].y) return false;
}
return true;
}
// The segment index a motion is currently on and how far into it it is.
export function pathProgress(motion) {
let remaining = motion.pathHours;
let index = 0;
while (index < motion.segmentHours.length) {
const step = motion.segmentHours[index];
if (step <= 0) {
index += 1;
continue;
}
if (remaining < step) break;
remaining -= step;
index += 1;
}
return { index, remaining };
}
export function nearestCorners(center, target) {
const half = { x: HEX_W / 2, y: HEX_H / 2 };
const corners = [
{ x: half.x, y: 0 },
{ x: half.x * 0.5, y: -half.y },
{ x: -half.x * 0.5, y: -half.y },
{ x: -half.x, y: 0 },
{ x: -half.x * 0.5, y: half.y },
{ x: half.x * 0.5, y: half.y },
];
let first = 0;
let second = 1;
let firstDistance = Infinity;
let secondDistance = Infinity;
for (let i = 0; i < corners.length; i++) {
const point = { x: center.x + corners[i].x, y: center.y + corners[i].y };
const distance = (point.x - target.x) ** 2 + (point.y - target.y) ** 2;
if (distance < firstDistance) {
secondDistance = firstDistance;
second = first;
firstDistance = distance;
first = i;
} else if (distance < secondDistance) {
secondDistance = distance;
second = i;
}
}
return [
{ x: center.x + corners[first].x, y: center.y + corners[first].y },
{ x: center.x + corners[second].x, y: center.y + corners[second].y },
];
}
// Signed distance to nudge an edge segment along its normal so it moves towards
// `center` (the owning tile). Used to keep neighbours' borders side by side
// instead of overlapping on the shared edge.
export function inwardOffset(a, b, center, distance) {
const dx = b.x - a.x;
const dy = b.y - a.y;
const length = Math.hypot(dx, dy) || 1;
const nx = -dy / length;
const ny = dx / length;
const midX = (a.x + b.x) / 2;
const midY = (a.y + b.y) / 2;
const dot = (center.x - midX) * nx + (center.y - midY) * ny;
return dot >= 0 ? distance : -distance;
}
export function makeSegment(a, b, colour, width, offset = 0) {
const div = document.createElement("div");
div.className = "border-seg";
const dx = b.x - a.x;
const dy = b.y - a.y;
const length = Math.hypot(dx, dy);
const angle = Math.atan2(dy, dx);
div.style.left = `${a.x}px`;
div.style.top = `${a.y - width / 2}px`;
div.style.width = `${length}px`;
div.style.height = `${width}px`;
div.style.background = colour;
div.style.transform = offset === 0
? `rotate(${angle}rad)`
: `rotate(${angle}rad) translateY(${offset}px)`;
div.style.borderRadius = `${width / 2}px`;
return div;
}
// A shaft plus a triangular head, from `a` to `b`, with the tip landing on
// `b`. `width` sets the shaft's thickness; the head grows with it so a thick
// arrow still reads as an arrow. A leg shorter than its head is drawn as just
// the head, so the shaft never pokes out behind it.
export function makeArrow(a, b, colour, width) {
const dx = b.x - a.x;
const dy = b.y - a.y;
const length = Math.hypot(dx, dy);
const angle = Math.atan2(dy, dx);
const head = Math.max(8, width * 2.4);
const group = document.createElement("div");
group.className = "migration-arrow";
if (length > head) {
const shaftEnd = {
x: b.x - (dx / length) * head,
y: b.y - (dy / length) * head,
};
group.appendChild(makeSegment(a, shaftEnd, colour, width));
}
const tip = document.createElement("div");
tip.className = "migration-arrow-head";
tip.style.left = `${b.x - head}px`;
tip.style.top = `${b.y - head / 2}px`;
tip.style.width = `${head}px`;
tip.style.height = `${head}px`;
tip.style.background = colour;
tip.style.transform = `rotate(${angle}rad)`;
group.appendChild(tip);
return group;
}
export function parseColour(value) {
if (value.charAt(0) === "#") {
const hex = value.slice(1);
return [
parseInt(hex.slice(0, 2), 16),
parseInt(hex.slice(2, 4), 16),
parseInt(hex.slice(4, 6), 16),
];
}
// Tolerates an "rgb(r, g, b)" string as well as a #rrggbb one.
const match = value.match(/(\d+)\D+(\d+)\D+(\d+)/);
if (match) return [Number(match[1]), Number(match[2]), Number(match[3])];
return [0, 0, 0];
}
export function clamp(value, min, max) {
if (min > max) return (min + max) / 2;
return Math.max(min, Math.min(max, value));
}