refactor: separate React views, Redux snapshots and Three.js runtime
This commit is contained in:
@@ -0,0 +1,462 @@
|
||||
// Tactical 2D maps drawn from the same OSM data as the 3D city. The region is too large for one
|
||||
// raster, so detail is drawn in lazily rendered, cached tiles and an overview covers zoomed-out views.
|
||||
const SCALE = 0.75; // tile pixels per metre
|
||||
const TILE_PX = 512,
|
||||
TILE_M = TILE_PX / SCALE,
|
||||
MAX_TILES = 48;
|
||||
const OVERVIEW_SCALE = 0.07;
|
||||
const AREA = {
|
||||
campus: '#28322c',
|
||||
park: '#2c4a32',
|
||||
grass: '#30503a',
|
||||
forest: '#27432e',
|
||||
pitch: '#33603c',
|
||||
water: '#2b5b6e',
|
||||
industrial: '#262c29',
|
||||
farmland: '#2a3326',
|
||||
};
|
||||
const ROAD_WIDTH = [11, 10, 9, 8, 6.5, 6, 5, 4, 4, 2.4, 2, 1.6, 3, 2.2];
|
||||
const roadColor = (type) => (type <= 2 ? '#d7dcd3' : type <= 7 ? '#8e9891' : '#5f6b63');
|
||||
const tileKey = (tx, tz) => tx * 100003 + tz;
|
||||
|
||||
function path(c, pts) {
|
||||
c.beginPath();
|
||||
c.moveTo(pts[0], pts[1]);
|
||||
for (let i = 2; i < pts.length; i += 2) c.lineTo(pts[i], pts[i + 1]);
|
||||
}
|
||||
|
||||
// Draws features in world metres; the caller sets up the transform. Minor roads go first.
|
||||
function drawFeatures(c, { areas, roads, buildings }, data, minWidth) {
|
||||
for (const area of areas) {
|
||||
c.fillStyle = AREA[data.areaKinds[area[0]]];
|
||||
path(c, area.slice(1));
|
||||
c.fill();
|
||||
}
|
||||
c.lineCap = c.lineJoin = 'round';
|
||||
for (const road of roads) {
|
||||
c.strokeStyle = roadColor(road[0]);
|
||||
c.lineWidth = Math.max(minWidth, ROAD_WIDTH[road[0]]);
|
||||
path(c, road.slice(2));
|
||||
c.stroke();
|
||||
}
|
||||
c.fillStyle = '#3c4943';
|
||||
for (const b of buildings) {
|
||||
path(c, b.slice(1));
|
||||
c.fill();
|
||||
}
|
||||
}
|
||||
|
||||
export function createMapAssets(data, extraLabels = []) {
|
||||
const { minX, minZ, maxX, maxZ } = data.bounds;
|
||||
// Bin every feature into the tiles its bounding box touches.
|
||||
const bins = new Map();
|
||||
const bin = (list, feature, first) => {
|
||||
let x0 = Infinity,
|
||||
z0 = Infinity,
|
||||
x1 = -Infinity,
|
||||
z1 = -Infinity;
|
||||
for (let i = first; i < feature.length; i += 2) {
|
||||
x0 = Math.min(x0, feature[i]);
|
||||
x1 = Math.max(x1, feature[i]);
|
||||
z0 = Math.min(z0, feature[i + 1]);
|
||||
z1 = Math.max(z1, feature[i + 1]);
|
||||
}
|
||||
for (let tx = Math.floor((x0 - 8) / TILE_M); tx <= Math.floor((x1 + 8) / TILE_M); tx++)
|
||||
for (let tz = Math.floor((z0 - 8) / TILE_M); tz <= Math.floor((z1 + 8) / TILE_M); tz++) {
|
||||
const k = tileKey(tx, tz);
|
||||
if (!bins.has(k)) bins.set(k, { areas: [], roads: [], buildings: [] });
|
||||
bins.get(k)[list].push(feature);
|
||||
}
|
||||
};
|
||||
data.areas.forEach((a) => bin('areas', a, 1));
|
||||
[...data.roads].sort((a, b) => b[0] - a[0]).forEach((r) => bin('roads', r, 2));
|
||||
data.buildings.forEach((b) => bin('buildings', b, 1));
|
||||
|
||||
const cache = new Map();
|
||||
function tile(tx, tz) {
|
||||
const k = tileKey(tx, tz);
|
||||
if (cache.has(k)) {
|
||||
const canvas = cache.get(k);
|
||||
cache.delete(k);
|
||||
cache.set(k, canvas);
|
||||
return canvas;
|
||||
}
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = canvas.height = TILE_PX;
|
||||
const c = canvas.getContext('2d');
|
||||
c.fillStyle = '#1b231f';
|
||||
c.fillRect(0, 0, TILE_PX, TILE_PX);
|
||||
c.setTransform(SCALE, 0, 0, SCALE, -tx * TILE_M * SCALE, -tz * TILE_M * SCALE);
|
||||
drawFeatures(c, bins.get(k) ?? { areas: [], roads: [], buildings: [] }, data, 1.6);
|
||||
cache.set(k, canvas);
|
||||
if (cache.size > MAX_TILES) cache.delete(cache.keys().next().value);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
const overview = document.createElement('canvas');
|
||||
overview.width = Math.ceil((maxX - minX) * OVERVIEW_SCALE);
|
||||
overview.height = Math.ceil((maxZ - minZ) * OVERVIEW_SCALE);
|
||||
const o = overview.getContext('2d');
|
||||
o.fillStyle = '#1b231f';
|
||||
o.fillRect(0, 0, overview.width, overview.height);
|
||||
o.setTransform(
|
||||
OVERVIEW_SCALE,
|
||||
0,
|
||||
0,
|
||||
OVERVIEW_SCALE,
|
||||
-minX * OVERVIEW_SCALE,
|
||||
-minZ * OVERVIEW_SCALE,
|
||||
);
|
||||
drawFeatures(
|
||||
o,
|
||||
{
|
||||
areas: data.areas,
|
||||
roads: data.roads.filter((r) => r[0] <= 5).sort((a, b) => b[0] - a[0]),
|
||||
buildings: [],
|
||||
},
|
||||
data,
|
||||
14,
|
||||
);
|
||||
|
||||
// One label per named boulevard, at the middle of its longest way.
|
||||
const streets = new Map();
|
||||
for (const road of data.roads) {
|
||||
if (road[1] < 0 || road[0] > 3) continue;
|
||||
let len = 0;
|
||||
for (let i = 4; i < road.length; i += 2)
|
||||
len += Math.hypot(road[i] - road[i - 2], road[i + 1] - road[i - 1]);
|
||||
const name = data.names[road[1]];
|
||||
if ((streets.get(name)?.len ?? 0) < len) {
|
||||
const mid = Math.floor((road.length - 2) / 4) * 2 + 2;
|
||||
const angle = Math.atan2(
|
||||
road[Math.min(mid + 3, road.length - 1)] - road[mid - 1],
|
||||
road[Math.min(mid + 2, road.length - 2)] - road[mid - 2],
|
||||
);
|
||||
streets.set(name, { name, len, x: road[mid], z: road[mid + 1], angle });
|
||||
}
|
||||
}
|
||||
return {
|
||||
tile,
|
||||
overview,
|
||||
bounds: data.bounds,
|
||||
labels: [...data.labels, ...extraLabels],
|
||||
streets: [...streets.values()].filter((s) => s.len > 150),
|
||||
};
|
||||
}
|
||||
|
||||
// Paints the map for the world rectangle x0..x1, z0..z1 into a context already transformed to world
|
||||
// metres; pxPerMetre decides between detailed tiles and the overview.
|
||||
function paintWorld(c, assets, x0, z0, x1, z1, pxPerMetre) {
|
||||
const { minX, minZ } = assets.bounds;
|
||||
c.fillStyle = '#141b18';
|
||||
c.fillRect(x0, z0, x1 - x0, z1 - z0);
|
||||
if (pxPerMetre < 0.22) {
|
||||
c.drawImage(
|
||||
assets.overview,
|
||||
minX,
|
||||
minZ,
|
||||
assets.overview.width / OVERVIEW_SCALE,
|
||||
assets.overview.height / OVERVIEW_SCALE,
|
||||
);
|
||||
return;
|
||||
}
|
||||
for (let tx = Math.floor(x0 / TILE_M); tx <= Math.floor(x1 / TILE_M); tx++)
|
||||
for (let tz = Math.floor(z0 / TILE_M); tz <= Math.floor(z1 / TILE_M); tz++)
|
||||
c.drawImage(assets.tile(tx, tz), tx * TILE_M, tz * TILE_M, TILE_M + 0.5, TILE_M + 0.5);
|
||||
}
|
||||
|
||||
function rotatePoint(x, z, cx, cz, angle, k) {
|
||||
const dx = (x - cx) * k,
|
||||
dz = (z - cz) * k,
|
||||
cos = Math.cos(angle),
|
||||
sin = Math.sin(angle);
|
||||
return [dx * cos - dz * sin, dx * sin + dz * cos];
|
||||
}
|
||||
|
||||
function machineIcon(c, x, y, { objective, visited, open }) {
|
||||
c.save();
|
||||
c.translate(x, y);
|
||||
if (objective) {
|
||||
c.rotate(Math.PI / 4);
|
||||
c.fillStyle = '#f2c94c';
|
||||
c.strokeStyle = '#1b1406';
|
||||
c.lineWidth = 2;
|
||||
c.fillRect(-7, -7, 14, 14);
|
||||
c.strokeRect(-7, -7, 14, 14);
|
||||
} else {
|
||||
c.fillStyle = open ? (visited ? '#7fb8ad' : '#3ee0c9') : '#7b817f';
|
||||
c.strokeStyle = '#08201c';
|
||||
c.lineWidth = 2;
|
||||
c.beginPath();
|
||||
c.roundRect(-6, -8, 12, 16, 3);
|
||||
c.fill();
|
||||
c.stroke();
|
||||
c.fillStyle = '#08201c';
|
||||
c.fillRect(-3, -5, 6, 6);
|
||||
}
|
||||
c.restore();
|
||||
if (visited) {
|
||||
c.fillStyle = '#fff';
|
||||
c.font = 'bold 11px sans-serif';
|
||||
c.fillText('✓', x + 8, y - 6);
|
||||
}
|
||||
}
|
||||
|
||||
// Circular minimap rotated so the camera's forward direction points up.
|
||||
export function drawMinimap(ctx, assets, view) {
|
||||
const { size, x, z, heading, camForward, radius, markers, route } = view;
|
||||
const r = size / 2,
|
||||
k = r / radius,
|
||||
angle = -Math.PI / 2 - Math.atan2(camForward.z, camForward.x);
|
||||
ctx.clearRect(0, 0, size, size);
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.arc(r, r, r - 2, 0, Math.PI * 2);
|
||||
ctx.clip();
|
||||
ctx.fillStyle = '#141b18';
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
ctx.translate(r, r);
|
||||
ctx.save();
|
||||
ctx.rotate(angle);
|
||||
ctx.scale(k, k);
|
||||
ctx.translate(-x, -z);
|
||||
paintWorld(
|
||||
ctx,
|
||||
assets,
|
||||
x - radius * 1.5,
|
||||
z - radius * 1.5,
|
||||
x + radius * 1.5,
|
||||
z + radius * 1.5,
|
||||
k,
|
||||
);
|
||||
ctx.restore();
|
||||
if (route?.length > 1) {
|
||||
ctx.strokeStyle = '#f2c94c';
|
||||
ctx.lineWidth = 3;
|
||||
ctx.setLineDash([6, 4]);
|
||||
ctx.beginPath();
|
||||
route.forEach((p, i) => {
|
||||
const [px, py] = rotatePoint(p.x, p.z, x, z, angle, k);
|
||||
i ? ctx.lineTo(px, py) : ctx.moveTo(px, py);
|
||||
});
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
for (const m of markers) {
|
||||
let [px, py] = rotatePoint(m.x, m.z, x, z, angle, k);
|
||||
const d = Math.hypot(px, py);
|
||||
if (d > r - 12) {
|
||||
if (!m.objective) continue;
|
||||
px *= (r - 12) / d;
|
||||
py *= (r - 12) / d;
|
||||
}
|
||||
machineIcon(ctx, px, py, m);
|
||||
}
|
||||
// Player arrow, then north marker on the rim.
|
||||
ctx.save();
|
||||
ctx.rotate(angle + Math.atan2(-Math.sin(heading), Math.cos(heading)) + Math.PI / 2);
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.strokeStyle = '#0b100e';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, -10);
|
||||
ctx.lineTo(7, 8);
|
||||
ctx.lineTo(0, 4);
|
||||
ctx.lineTo(-7, 8);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
const [nx, ny] = rotatePoint(x, z - 1, x, z, angle, 1);
|
||||
const nl = Math.hypot(nx, ny) || 1;
|
||||
ctx.fillStyle = '#f2c94c';
|
||||
ctx.font = 'bold 13px Vazirmatn, sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText('ش', (nx / nl) * (r - 12), (ny / nl) * (r - 12));
|
||||
ctx.restore();
|
||||
ctx.strokeStyle = 'rgba(230,240,235,.55)';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.arc(r, r, r - 2, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Full-screen city map. view = {cx, cz, mpp} (centre in metres, metres per CSS pixel).
|
||||
export function drawFullMap(ctx, assets, view, state) {
|
||||
const { width, height } = ctx.canvas,
|
||||
dpr = state.dpr,
|
||||
w = width / dpr,
|
||||
h = height / dpr;
|
||||
const toScreen = (x, z) => [(x - view.cx) / view.mpp + w / 2, (z - view.cz) / view.mpp + h / 2];
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.fillStyle = '#101614';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
ctx.save();
|
||||
ctx.translate(w / 2, h / 2);
|
||||
ctx.scale(1 / view.mpp, 1 / view.mpp);
|
||||
ctx.translate(-view.cx, -view.cz);
|
||||
paintWorld(
|
||||
ctx,
|
||||
assets,
|
||||
view.cx - (w / 2) * view.mpp,
|
||||
view.cz - (h / 2) * view.mpp,
|
||||
view.cx + (w / 2) * view.mpp,
|
||||
view.cz + (h / 2) * view.mpp,
|
||||
1 / view.mpp,
|
||||
);
|
||||
ctx.restore();
|
||||
// Grid lines every 500 m, like a tactical map.
|
||||
ctx.strokeStyle = 'rgba(160,200,185,.08)';
|
||||
ctx.lineWidth = 1;
|
||||
for (let gx = Math.ceil(assets.bounds.minX / 500) * 500; gx < assets.bounds.maxX; gx += 500) {
|
||||
const [sx] = toScreen(gx, 0);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(sx, 0);
|
||||
ctx.lineTo(sx, h);
|
||||
ctx.stroke();
|
||||
}
|
||||
for (let gz = Math.ceil(assets.bounds.minZ / 500) * 500; gz < assets.bounds.maxZ; gz += 500) {
|
||||
const [, sy] = toScreen(0, gz);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, sy);
|
||||
ctx.lineTo(w, sy);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
if (view.mpp < 3) {
|
||||
ctx.font = '11px Vazirmatn, sans-serif';
|
||||
ctx.fillStyle = 'rgba(235,240,232,.75)';
|
||||
for (const s of assets.streets) {
|
||||
const [sx, sy] = toScreen(s.x, s.z);
|
||||
if (sx < -100 || sy < -20 || sx > w + 100 || sy > h + 20) continue;
|
||||
ctx.save();
|
||||
ctx.translate(sx, sy);
|
||||
let a = s.angle;
|
||||
if (a > Math.PI / 2) a -= Math.PI;
|
||||
if (a < -Math.PI / 2) a += Math.PI;
|
||||
ctx.rotate(a);
|
||||
ctx.fillText(s.name, 0, 0);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
for (const l of assets.labels) {
|
||||
if (l.kind === 'school' && view.mpp > 1.6) continue;
|
||||
const [sx, sy] = toScreen(l.x, l.z);
|
||||
ctx.font = `${l.kind === 'school' ? 10 : 12}px Vazirmatn, sans-serif`;
|
||||
if (l.kind === 'industrial' && view.mpp > 6) continue;
|
||||
ctx.fillStyle = {
|
||||
park: '#9fd49a',
|
||||
university: '#c9b6f2',
|
||||
school: '#e8c79a',
|
||||
metro: '#ff8a80',
|
||||
mall: '#f5b8d9',
|
||||
industrial: '#a9b2ad',
|
||||
factory: '#3ee0c9',
|
||||
}[l.kind];
|
||||
if (l.kind === 'factory') ctx.font = 'bold 14px Vazirmatn, sans-serif';
|
||||
ctx.fillText(
|
||||
l.kind === 'metro' ? `Ⓜ ${l.name.replace('ایستگاه مترو ', '')}` : l.name,
|
||||
sx,
|
||||
sy + 14,
|
||||
);
|
||||
}
|
||||
if (state.route?.length > 1) {
|
||||
ctx.strokeStyle = '#f2c94c';
|
||||
ctx.lineWidth = 4;
|
||||
ctx.setLineDash([8, 5]);
|
||||
ctx.beginPath();
|
||||
state.route.forEach((p, i) => {
|
||||
const [sx, sy] = toScreen(p.x, p.z);
|
||||
i ? ctx.lineTo(sx, sy) : ctx.moveTo(sx, sy);
|
||||
});
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
for (const m of state.markers) {
|
||||
const [sx, sy] = toScreen(m.x, m.z);
|
||||
if (m.id === state.selected) {
|
||||
ctx.strokeStyle = '#ffffff';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.arc(sx, sy, 16, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
}
|
||||
machineIcon(ctx, sx, sy, m);
|
||||
}
|
||||
const [px, py] = toScreen(state.player.x, state.player.z);
|
||||
ctx.save();
|
||||
ctx.translate(px, py);
|
||||
ctx.rotate(
|
||||
Math.atan2(-Math.sin(state.player.heading), Math.cos(state.player.heading)) + Math.PI / 2,
|
||||
);
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.strokeStyle = '#000';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, -12);
|
||||
ctx.lineTo(8, 9);
|
||||
ctx.lineTo(0, 5);
|
||||
ctx.lineTo(-8, 9);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
return toScreen;
|
||||
}
|
||||
|
||||
// Iran overview for the briefing and the map's country tab. `t` animates the highlight.
|
||||
export function drawIran(
|
||||
ctx,
|
||||
iran,
|
||||
{ width, height, t = 0, highlight = 'Razavi Khorasan', cities = [] },
|
||||
) {
|
||||
const lon0 = 43.8,
|
||||
lon1 = 63.6,
|
||||
lat0 = 24.8,
|
||||
lat1 = 40;
|
||||
const kx = 0.84,
|
||||
k = Math.min(width / ((lon1 - lon0) * kx), height / (lat1 - lat0)) * 0.94;
|
||||
const offX = (width - (lon1 - lon0) * kx * k) / 2,
|
||||
offY = (height - (lat1 - lat0) * k) / 2;
|
||||
const p = (lon, lat) => [offX + (lon - lon0) * kx * k, offY + (lat1 - lat) * k];
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
for (const province of iran.provinces) {
|
||||
const active = province.id === highlight;
|
||||
ctx.fillStyle = active
|
||||
? `rgba(62,224,201,${0.35 + Math.sin(t * 3) * 0.12})`
|
||||
: 'rgba(40,56,50,.85)';
|
||||
ctx.strokeStyle = active ? '#3ee0c9' : 'rgba(150,190,175,.35)';
|
||||
ctx.lineWidth = active ? 2 : 1;
|
||||
for (const ring of province.rings) {
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < ring.length; i += 2) {
|
||||
const [x, y] = p(ring[i], ring[i + 1]);
|
||||
i ? ctx.lineTo(x, y) : ctx.moveTo(x, y);
|
||||
}
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
ctx.textAlign = 'center';
|
||||
for (const city of cities) {
|
||||
const [x, y] = p(city.lon, city.lat);
|
||||
if (city.active) {
|
||||
ctx.strokeStyle = '#f2c94c';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, 6 + ((t * 18) % 18), 0, Math.PI * 2);
|
||||
ctx.globalAlpha = 1 - ((t * 18) % 18) / 18;
|
||||
ctx.stroke();
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
ctx.fillStyle = city.active ? '#f2c94c' : 'rgba(210,220,214,.6)';
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, city.active ? 5 : 3, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.font = `${city.active ? 'bold 15px' : '11px'} Vazirmatn, sans-serif`;
|
||||
ctx.fillText(city.active ? city.name : `${city.name} 🔒`, x, y - 10);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user