loko/streetup/assets/static/assets/sign_panels_shared.js
2026-07-22 14:48:40 +02:00

355 lines
12 KiB
JavaScript

/* sign_panels_shared.js
* Shared sign panel map rendering helpers used by both:
* - sign app map
* - unified assets map
*/
(function (global) {
'use strict';
const MIN_PANEL_BASE_UP_OFFSET_PX = 40;
const DEFAULT_PANEL_ICON_HEIGHT_PX = 65;
const PANEL_STACK_GAP_PX = 2;
const SCALED_PANEL_ICON_FACTOR = 1.2;
const loadingRoadSignImagesByMap = new WeakMap();
function getLoadingSet(map) {
let set = loadingRoadSignImagesByMap.get(map);
if (!set) {
set = new Set();
loadingRoadSignImagesByMap.set(map, set);
}
return set;
}
function getRenderedPanelHeight(feature) {
const props = feature && feature.properties ? feature.properties : {};
const scaleFactor = Number(props.panel_scale_factor) || 1.0;
const spriteId = props.panel_text_image_id;
const metrics = (typeof global !== 'undefined') ? global.panelTextSpriteMetrics : null;
const iconMetrics = (typeof global !== 'undefined') ? global.panelRoadSignSpriteMetrics : null;
const iconSpriteId = props.panel_icon_image_scaled || props.panel_icon_image;
let height;
if (props.panel_text_can_render && spriteId && metrics && metrics[spriteId] && metrics[spriteId].height) {
height = metrics[spriteId].height * scaleFactor;
} else if (!props.panel_text_can_render && iconSpriteId && iconMetrics && iconMetrics[iconSpriteId] && iconMetrics[iconSpriteId].height) {
height = iconMetrics[iconSpriteId].height * scaleFactor;
} else if (!props.panel_text_can_render && props.panel_icon_image_scaled) {
height = (props.panel_render_height || DEFAULT_PANEL_ICON_HEIGHT_PX) * SCALED_PANEL_ICON_FACTOR * scaleFactor;
} else {
height = (props.panel_render_height || DEFAULT_PANEL_ICON_HEIGHT_PX) * scaleFactor;
}
return Number.isFinite(height) && height > 0 ? height : DEFAULT_PANEL_ICON_HEIGHT_PX;
}
function calculatePanelOffsets(geojsonData) {
if (!geojsonData || !Array.isArray(geojsonData.features) || geojsonData.features.length === 0) {
return geojsonData;
}
const panelsByPole = {};
geojsonData.features.forEach(feature => {
if (feature && feature.properties && feature.properties.asset_type === 'signpanel') {
const poleId = feature.properties.pole_id;
if (!poleId) return;
if (!panelsByPole[poleId]) panelsByPole[poleId] = [];
panelsByPole[poleId].push(feature);
}
});
Object.values(panelsByPole).forEach(polePanels => {
polePanels.sort((a, b) => {
const orderA = a && a.properties ? (a.properties.panel_vertical_order || 1) : 1;
const orderB = b && b.properties ? (b.properties.panel_vertical_order || 1) : 1;
return orderA - orderB;
});
let cumulativeOffset = -MIN_PANEL_BASE_UP_OFFSET_PX;
let previousHeight = 0;
for (let i = 0; i < polePanels.length; i++) {
const panel = polePanels[i];
const renderHeight = getRenderedPanelHeight(panel);
if (i === 0) {
cumulativeOffset = -MIN_PANEL_BASE_UP_OFFSET_PX - Math.max(0, (renderHeight - DEFAULT_PANEL_ICON_HEIGHT_PX) / 2);
} else {
cumulativeOffset -= (previousHeight + renderHeight) / 2 + PANEL_STACK_GAP_PX;
}
if (!panel.properties) panel.properties = {};
const panelScaleFactor = Number(panel.properties.panel_scale_factor) || 1.0;
let offsetVal = (cumulativeOffset - 64) / panelScaleFactor;
if (!Number.isFinite(offsetVal)) {
offsetVal = -64;
}
panel.properties.panel_icon_offset = [0, offsetVal];
panel.properties.panel_text_offset = [0, offsetVal];
previousHeight = renderHeight;
}
});
return geojsonData;
}
function addOrReplaceImage(map, name, payload, options) {
if (!map || !name || !payload) return;
if (map.hasImage(name)) {
try {
map.updateImage(name, payload);
return;
} catch (_err) {
try {
map.removeImage(name);
} catch (_removeErr) {
// ignore
}
}
}
if (options) {
map.addImage(name, payload, options);
} else {
map.addImage(name, payload);
}
}
function createDefaultRoadSignPayload(size, color) {
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(size / 2, size / 2, (size / 2) - 2, 0, 2 * Math.PI);
ctx.fill();
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
return {
width: canvas.width,
height: canvas.height,
data: imageData.data,
};
}
function createOrientationArrowPayload() {
const canvas = document.createElement('canvas');
canvas.width = 48;
canvas.height = 48;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#aaaaaa';
ctx.beginPath();
ctx.moveTo(24, 6);
ctx.lineTo(34, 27);
ctx.lineTo(26.5, 27);
ctx.lineTo(26.5, 42);
ctx.lineTo(21.5, 42);
ctx.lineTo(21.5, 27);
ctx.lineTo(14, 27);
ctx.closePath();
ctx.fill();
return {
width: canvas.width,
height: canvas.height,
data: ctx.getImageData(0, 0, canvas.width, canvas.height).data,
};
}
function createPoleVerticalPayload() {
const canvas = document.createElement('canvas');
canvas.width = 16;
canvas.height = 64;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#555555';
ctx.strokeStyle = '#222222';
ctx.lineWidth = 1;
const poleWidth = 4;
const poleX = Math.floor((canvas.width - poleWidth) / 2);
ctx.beginPath();
ctx.moveTo(poleX + poleWidth, 0);
ctx.lineTo(poleX + poleWidth, canvas.height);
ctx.lineTo(poleX, canvas.height);
ctx.lineTo(poleX, 0);
ctx.closePath();
ctx.fill();
ctx.stroke();
return {
width: canvas.width,
height: canvas.height,
data: ctx.getImageData(0, 0, canvas.width, canvas.height).data,
};
}
function loadRoadSignImages(map) {
if (!map) return;
if (map.hasImage('road-sign-images-loaded')) return;
global.panelRoadSignSpriteMetrics = global.panelRoadSignSpriteMetrics || {};
addOrReplaceImage(map, 'road-sign-default', createDefaultRoadSignPayload(64, '#0022ff'));
addOrReplaceImage(map, 'orientation-arrow', createOrientationArrowPayload());
addOrReplaceImage(map, 'sign-pole-vertical', createPoleVerticalPayload());
map.addImage('road-sign-images-loaded', {
width: 1,
height: 1,
data: new Uint8Array([0, 0, 0, 0]),
});
}
function handleRoadSignStyleImageMissing(args) {
const map = args && args.map;
const event = args && args.event;
const onImageUpdated = args && args.onImageUpdated;
const missingId = event && event.id;
if (!map || !missingId) return false;
if (!missingId.startsWith('road-sign-') || missingId === 'road-sign-default') return false;
const loadingSet = getLoadingSet(map);
if (map.hasImage(missingId) || loadingSet.has(missingId)) {
return true;
}
const isScaledX12 = missingId.endsWith('-x12');
const targetSize = isScaledX12 ? Math.round(64 * 1.2) : 64;
const signCode = missingId.replace('road-sign-', '').replace(/-x12$/, '');
const greyCanvas = document.createElement('canvas');
greyCanvas.width = targetSize;
greyCanvas.height = targetSize;
const greyCtx = greyCanvas.getContext('2d');
greyCtx.fillStyle = '#d0d0d0';
const inset = Math.max(8, Math.round(targetSize * 0.25));
greyCtx.fillRect(inset, inset, greyCanvas.width - (2 * inset), greyCanvas.height - (2 * inset));
greyCtx.strokeStyle = '#7a7a7a';
greyCtx.lineWidth = 2;
greyCtx.strokeRect(inset + 2, inset + 2, greyCanvas.width - (2 * inset) - 4, greyCanvas.height - (2 * inset) - 4);
addOrReplaceImage(map, missingId, {
width: greyCanvas.width,
height: greyCanvas.height,
data: greyCtx.getImageData(0, 0, greyCanvas.width, greyCanvas.height).data,
});
const img = new Image();
img.crossOrigin = 'anonymous';
let attemptCount = 0;
const codeVariations = [
signCode.toUpperCase(),
signCode.charAt(0).toUpperCase() + signCode.slice(1),
signCode.toLowerCase(),
];
function tryNextVariation() {
if (attemptCount >= codeVariations.length * 2) {
return;
}
const currentVariation = codeVariations[Math.floor(attemptCount / 2)];
const extension = attemptCount % 2 === 0 ? 'svg' : 'png';
const url = `/static/assets/road_signs/2025/${currentVariation}.${extension}`;
attemptCount += 1;
img.src = url;
}
loadingSet.add(missingId);
img.onload = function () {
try {
const aspectRatio = img.width > 0 ? (img.height / img.width) : 1;
const spriteWidth = targetSize;
const spriteHeight = Math.round(targetSize * aspectRatio);
const canvas = document.createElement('canvas');
canvas.width = spriteWidth;
canvas.height = spriteHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, spriteWidth, spriteHeight);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
if (map.hasImage(missingId)) {
map.removeImage(missingId);
}
map.addImage(missingId, {
width: canvas.width,
height: canvas.height,
data: imageData.data,
});
global.panelRoadSignSpriteMetrics[missingId] = {
width: canvas.width,
height: canvas.height,
};
map.triggerRepaint();
if (typeof onImageUpdated === 'function') {
onImageUpdated();
}
} catch (err) {
console.warn('Error updating road sign image', missingId, err);
} finally {
loadingSet.delete(missingId);
}
};
img.onerror = function () {
tryNextVariation();
if (attemptCount >= codeVariations.length * 2) {
loadingSet.delete(missingId);
}
};
tryNextVariation();
return true;
}
function refreshPanelOffsetsForSource(map, sourceId) {
if (!map || !sourceId || !map.getSource) return;
const source = map.getSource(sourceId);
if (!source || !source._data || !Array.isArray(source._data.features)) return;
calculatePanelOffsets(source._data);
source.setData(source._data);
}
function createPanelOffsetRefreshScheduler(map, sourceId, delayMs) {
const delay = Number.isFinite(delayMs) ? delayMs : 40;
let timer = null;
return function schedule() {
if (timer) clearTimeout(timer);
timer = setTimeout(function () {
timer = null;
refreshPanelOffsetsForSource(map, sourceId);
}, delay);
};
}
global.SignPanelsShared = {
getRenderedPanelHeight,
calculatePanelOffsets,
loadRoadSignImages,
handleRoadSignStyleImageMissing,
refreshPanelOffsetsForSource,
createPanelOffsetRefreshScheduler,
};
})(window);