355 lines
12 KiB
JavaScript
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);
|