loko/streetup/assets/templates/assets/sign_index.html
2026-07-22 14:48:40 +02:00

1536 lines
63 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends "assets/base_assets_index.html" %}
{% load static i18n %}
{% block locations_filter_form %}
<form method="get" id="filter-form" class="row g-3 mb-3 me-3" style="width: 400px;">
<div class="col-12">
<label for="municipality" class="form-label">{% translate "Commune" %}</label>
<select name="municipality" id="municipality" class="form-select" multiple>
{% for key, value in municipality_choices %}
<option value="{{ key }}" {% if key in selected_municipalities %}selected{% endif %}>
{{ value }}
</option>
{% endfor %}
</select>
</div>
<div class="col-12 mt-4">
<button type="button" id="apply-filters" class="btn btn-sm btn-primary">{% translate "Filtrer" %}</button>
<button type="button" id="reset-filters" class="btn btn-sm btn-secondary">{% translate "Réinitialiser" %}</button>
</div>
</form>
{% endblock locations_filter_form %}
{% block assets_filter_form %}
<form method="get" id="assets-filter-form" class="row g-3 mb-3 me-3" style="width: 600px;">
<div class="col-6">
<label for="category" class="form-label">{% translate "Catégorie" %}</label>
<select name="category" id="category" class="form-select" multiple>
{% for key, value in category_choices %}
<option value="{{ key }}" {% if key in selected_categories %}selected{% endif %}>
{{ value }}
</option>
{% endfor %}
</select>
</div>
<div class="col-6">
<label for="model" class="form-label">{% translate "Modèle" %}</label>
<select name="model" id="model" class="form-select" multiple>
{% for key, value in model_choices %}
<option value="{{ key }}" {% if key in selected_models %}selected{% endif %}>
{{ value }}
</option>
{% endfor %}
</select>
</div>
<div class="col-6">
<label for="panel_type" class="form-label">{% translate "Type de panneau" %}</label>
<select name="panel_type" id="panel_type_select" class="form-select" multiple>
{% for key, value in panel_type_choices %}
<option value="{{ key }}" {% if key in selected_panel_types %}selected{% endif %}>
{{ value }}
</option>
{% endfor %}
</select>
</div>
<div class="col-6"></div>
<div class="col-12 d-flex justify-content-between mt-4" style="width: 200px;">
<button type="button" id="apply-assets-filters" class="btn btn-sm btn-primary">{% translate "Filtrer" %}</button>
<button type="button" id="reset-assets-filters" class="btn btn-sm btn-secondary">{% translate "Réinitialiser" %}</button>
</div>
</form>
{% endblock assets_filter_form %}
{% block const_script %}
<script>
const locationsgeojsonUrl = "{% url 'assets:sign_streets_geojson' %}";
const assetsgeojsonUrl = "{% url 'assets:sign_assets_geojson' %}";
const locationsurl = "{% url 'assets:sign_streets' %}";
const assetsurl = "{% url 'assets:sign_index' %}";
var locations_property_fields = ['name', 'municipality'];
var locations_columns = [
{ field: 'name', title: '{% translate "Nom" %}' },
{ field: 'municipality', title: '{% translate "Commune" %}', align: 'center' }
];
var assets_property_fields = ['name', 'category', 'model', 'panel_type', 'panel_text'];
var assets_columns = [
// { field: 'name', title: '{% translate "Nom" %}' },
{ field: 'category', title: '{% translate "Catégorie" %}' },
{ field: 'model', title: '{% translate "Modèle" %}' },
{ field: 'panel_type', title: '{% translate "Type" %}' },
{ field: 'panel_text', title: '{% translate "Texte" %}' }
]
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;
let panelOffsetRefreshTimer = null;
function getRenderedPanelHeight(feature) {
const props = feature?.properties || {};
const scaleFactor = props.panel_scale_factor || 1.0;
const spriteId = props.panel_text_image_id;
const textMetrics = window.panelTextSpriteMetrics || {};
const iconMetrics = window.panelRoadSignSpriteMetrics || {};
const iconSpriteId = props.panel_icon_image_scaled || props.panel_icon_image;
if (props.panel_text_can_render && spriteId && textMetrics[spriteId]?.height) {
return textMetrics[spriteId].height * scaleFactor;
}
if (!props.panel_text_can_render && iconSpriteId && iconMetrics[iconSpriteId]?.height) {
return iconMetrics[iconSpriteId].height * scaleFactor;
}
if (!props.panel_text_can_render && props.panel_icon_image_scaled) {
return (props.panel_render_height || DEFAULT_PANEL_ICON_HEIGHT_PX) * SCALED_PANEL_ICON_FACTOR * scaleFactor;
}
return (props.panel_render_height || DEFAULT_PANEL_ICON_HEIGHT_PX) * scaleFactor;
}
function refreshVisiblePanelOffsets() {
const activeMap = panelTextSpriteMap;
if (!activeMap || !activeMap.getSource) {
return;
}
const source = activeMap.getSource('assets');
if (!source || !source._data || !Array.isArray(source._data.features)) {
return;
}
calculatePanelOffsets(source._data.features);
source.setData(source._data);
}
function schedulePanelOffsetRefresh() {
if (panelOffsetRefreshTimer) {
clearTimeout(panelOffsetRefreshTimer);
}
panelOffsetRefreshTimer = setTimeout(() => {
panelOffsetRefreshTimer = null;
refreshVisiblePanelOffsets();
}, 40);
}
window.panelRoadSignSpriteMetrics = window.panelRoadSignSpriteMetrics || {};
window.panelTextSpriteMetrics = window.panelTextSpriteMetrics || {};
window.addEventListener('panelTextSpriteRendered', () => {
schedulePanelOffsetRefresh();
});
function calculatePanelOffsets(features) {
if (!Array.isArray(features) || features.length === 0) {
return;
}
const panelsByPole = {};
features.forEach((feature) => {
if (feature?.properties?.asset_type !== 'signpanel') {
return;
}
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?.properties?.panel_vertical_order || 1;
const orderB = b?.properties?.panel_vertical_order || 1;
return orderA - orderB;
});
let cumulativeOffset = -MIN_PANEL_BASE_UP_OFFSET_PX;
let previousHeight = 0;
polePanels.forEach((panel, index) => {
const renderHeight = getRenderedPanelHeight(panel);
if (index === 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;
}
const panelScaleFactor = panel.properties.panel_scale_factor || 1.0;
panel.properties.panel_stack_offset = [0, cumulativeOffset / panelScaleFactor];
previousHeight = renderHeight;
});
});
}
const locations_layer_def = {
id: 'locations',
type: 'fill',
source: 'locations',
paint: {
'fill-color': '#0094ff',
'fill-opacity': 0.1,
}
};
var locations_outline_layer;
// Circle layer for low zoom levels
const assets_layer_def = {
id: 'assets',
type: 'circle',
source: 'assets',
paint: {
'circle-radius': [
'interpolate',
['linear'],
['zoom'],
8, 1,
16, 5
],
'circle-stroke-width': [
'case',
['boolean', ['feature-state', 'highlighted'], false], 3,
0
],
'circle-color': '#0022ff',
'circle-opacity': 0.7
},
minzoom: 8,
maxzoom: 17
};
// Symbol layer for road sign panels at high zoom
const panels_symbol_layer_def = {
id: 'assets-panels-symbols',
type: 'symbol',
source: 'assets',
filter: ['==', ['get', 'asset_type'], 'signpanel'],
layout: {
'icon-image': [
'coalesce',
['get', 'panel_text_image_id'],
['get', 'panel_icon_image_scaled'],
['get', 'panel_icon_image'],
'road-sign-default'
],
'icon-size': [
'interpolate',
['linear'],
['zoom'],
17, ['*', 0.3, ['to-number', ['get', 'panel_scale_factor'], 1.0]],
22, ['*', 0.8, ['to-number', ['get', 'panel_scale_factor'], 1.0]]
],
'icon-allow-overlap': true,
'icon-ignore-placement': true,
'icon-offset': [
'coalesce',
['get', 'panel_stack_offset'],
['get', 'panel_text_offset'],
['get', 'panel_icon_offset'],
['literal', [0, 0]]
],
'symbol-z-order': 'viewport-y',
'icon-rotate': 0
},
paint: {
'icon-opacity': [
'case',
['==', ['get', 'panel_hide_icon'], true],
['case', ['has', 'panel_text_image_id'], 0.8, 0],
0.8
]
},
minzoom: 17
};
// Orientation arrow layer for panels
const panels_orientation_layer_def = {
id: 'assets-panels-orientation',
type: 'symbol',
source: 'assets',
filter: [
'all',
['==', ['get', 'asset_type'], 'signpanel'],
['has', 'orientation'],
['==', ['get', 'show_orientation_arrow'], true]
],
layout: {
'icon-image': 'orientation-arrow',
'icon-size': [
'interpolate',
['linear'],
['zoom'],
17, 0.3,
22, 0.8
],
'icon-rotation-alignment': 'map',
'icon-pitch-alignment': 'map',
'icon-rotate': ['to-number', ['get', 'orientation'], 0],
'icon-allow-overlap': true,
'icon-ignore-placement': true,
'icon-offset': [
'coalesce',
['get', 'panel_orientation_offset'],
['literal', [0, 0]]
],
'symbol-z-order': 'viewport-y'
},
paint: {
'icon-opacity': 0.7
},
minzoom: 17
};
// Vertical pole line layer for high zoom (similar to sign.js)
const poles_vertical_layer_def = {
id: 'assets-poles-vertical',
type: 'symbol',
source: 'assets',
filter: ['==', ['get', 'asset_type'], 'signpole'],
layout: {
'icon-image': 'sign-pole-vertical',
'icon-anchor': 'top',
'icon-offset': ['literal', [0, -5]],
'icon-allow-overlap': true,
'icon-ignore-placement': true,
'icon-size': [
'interpolate', ['linear'], ['zoom'],
17, 0.6,
22, 1.0
],
'symbol-z-order': 'viewport-y'
},
paint: {
'icon-opacity': 0.9
},
minzoom: 17
};
const assets_layers_defs = [assets_layer_def, panels_symbol_layer_def, panels_orientation_layer_def, poles_vertical_layer_def];
</script>
{% endblock const_script %}
{% block extra_script %}
<script>
const ROAD_SIGN_MISSING_ICON = 'road-sign-missing';
let roadSignMissingImagePayload = null;
// List of road sign types - complete list from static files in 2025 directory
const roadSignTypes = [
// A & B series
"A11", "A13", "A14", "A15", "A17", "A19", "A1a", "A1b", "A1c", "A1d",
"A21", "A23", "A25", "A27", "A29", "A3", "A31", "A33", "A35", "A37", "A39",
"A41", "A43", "A45", "A47", "A49", "A5", "A50", "A51", "A7a", "A7b", "A7c", "A9",
"B1", "B11", "B13", "B15a", "B15b", "B15c", "B15d", "B15e", "B15f", "B15g",
"B17", "B19", "B21", "B22", "B23", "B3", "B5", "B7", "B9",
// C series + variants
"C1", "C11", "C13", "C15", "C17", "C19", "C21_5.5", "C22", "C23", "C24a", "C24b", "C24c",
"C25", "C27", "C29", "C3", "C31a", "C31b", "C33", "C35", "C37", "C39", "C41",
"C43", "C43_20", "C43_30", "C43_50", "C43_50km", "C43_60", "C43_70", "C43_80", "C43_90", "C43_100",
"C45", "C45_50", "C45_50km", "C46", "C47", "C48", "C49", "C5", "C6", "C7", "C5-C7", "C9", "C9-C11",
// D series (Dutch naming variants present)
"D10", "D11", "D13", "D1a", "D1b_links", "D1b_rechts", "D1c", "D1d", "D1e", "D1f",
"D3a", "D3b", "D4_links", "D4_rechts", "D4_rechtdoor", "D5", "D7", "D9", "D9a", "D9b",
// E series
"E1", "E11", "E3", "E5", "E7", "E9a", "E9a_Handicap", "E9a_Parkeerschijf",
"E9b", "E9c", "E9d", "E9e", "E9f", "E9g", "E9h", "E9i",
// F series + many variants
"F101a", "F101b", "F101c", "F103", "F105", "F11", "F111", "F113", "F117", "F118", "F119", "F120",
"F12a", "F12b", "F13", "F14", "F15", "F15_afrit", "F17", "F18", "F19",
"F1a_horizontaal", "F1a_verticaal", "F1b_horizontaal", "F1b_verticaal",
"F21", "F23a", "F23b", "F23c", "F23d", "F25", "F25M", "F25R", "F25V", "F25VM", "F25VR",
"F27", "F27V", "F29", "F31",
"F33a", "F33b", "F34a", "F34b1", "F34b2", "F34c1", "F34c2", "F35", "F37", "F39",
"F3a_horizontaal", "F3a_verticaal", "F3b_horizontaal", "F3b_verticaal",
"F41", "F43", "F45", "F45b", "F45_links", "F45_rechts", "F47", "F49", "F4a", "F4b", "F5",
"F50", "F50bis_A21", "F50bis_A25",
"F51a", "F51b", "F52a", "F52b", "F52bisa", "F52bisb", "F53", "F55", "F56", "F57",
"F59", "F59a", "F59b",
"F60", "F61", "F62", "F63", "F63_CNG", "F63_Elektrisch", "F63_H2", "F63_LNG", "F63_LPG",
"F65", "F67", "F69", "F7", "F71", "F73", "F75", "F77", "F79",
"F8", "F81", "F83", "F85", "F87", "F89", "F9", "F91", "F93", "F95", "F97", "F97_3-2", "F98",
"F99a", "F99b", "F99c",
// G series
"GF98", "GIa_150", "GIb_150",
"GII_10",
"GIII_Aquaplaning", "GIII_Bij_nat_wegdek", "GIII_Fabriek", "GIII_Ijzel", "GIII_Langsgroeven",
"GIII_Mist", "GIII_Spoorvorming", "GIII_Steengroeve", "GIII_Uitrit_vrachtwagens", "GIII_Vervormd_wegdek", "GIII_Zachte_berm",
"GIV_Uitgezonderd_-5t", "GIV_Uitgezonderd_bus", "GIV_Uitgezonderd_fietsers", "GIV_Uitgezonderd_plaatselijk_verkeer",
"GIX", "GIX_3-2", "GIX_2-vaks", "GIX_3-vaks", "GVI",
"GVIIa_+2t", "GVIIb", "GVIIb_uitgezonderd_bewoners", "GVIIc_30min", "GVIII",
"GV_Van_7_tot_19u", "GV_Van_maandag_tot_vrijdag",
"GVIId_Carpool", "GVIId_Elektrische_personenwagens", "GVIId_Elektrische_wagens", "GVIId_P+R", "GVIId_Handicap",
"GVII_betalend", "GVII_betalend_uitgezonderd_bewoners",
// GX series
"GXa", "GXb", "GXc", "GXd", "GXI", "GXII_B", "GXII_C", "GXII_D", "GXII_E",
// M series
"M1", "M10", "M11", "M12", "M13", "M14", "M15", "M16", "M17", "M18", "M19", "M20",
"M2", "M3", "M3bis", "M4", "M5", "M5bis", "M6", "M7", "M8", "M9",
// Misc
"IF2", "IR10b", "IR11", "IVMS1",
"S1", "S10", "S11", "S12", "S13", "S14", "S15", "S16", "S17", "S18", "S19",
"S2", "S20", "S21", "S3", "S30", "S31", "S32", "S33", "S34", "S35", "S36", "S4", "S5"
];
// Function to load road sign SVG images into the map
function loadRoadSignImages(map) {
if (map.hasImage('road-sign-images-loaded')) {
return;
}
const baseUrl = "{% static 'assets/road_signs/2025/' %}";
// Create a default placeholder image (circle)
const canvas = document.createElement('canvas');
canvas.width = 64;
canvas.height = 64;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#0022ff';
ctx.beginPath();
ctx.arc(32, 32, 30, 0, 2 * Math.PI);
ctx.fill();
const defaultCircleData = ctx.getImageData(0, 0, canvas.width, canvas.height);
function addOrReplaceImage(name, payload) {
if (map.hasImage(name)) {
map.updateImage(name, payload);
} else {
map.addImage(name, payload);
}
}
addOrReplaceImage('road-sign-default', {
width: canvas.width,
height: canvas.height,
data: defaultCircleData.data
});
// Create a grey square placeholder used for missing road sign types
const greyCanvas = document.createElement('canvas');
greyCanvas.width = 64;
greyCanvas.height = 64;
const greyCtx = greyCanvas.getContext('2d');
greyCtx.fillStyle = '#d0d0d0';
greyCtx.fillRect(16, 16, greyCanvas.width-32, greyCanvas.height-32);
greyCtx.strokeStyle = '#7a7a7a';
// greyCtx.lineWidth = 4;
// greyCtx.strokeRect(4, 4, greyCanvas.width - 8, greyCanvas.height - 8);
const greySquareImageData = greyCtx.getImageData(0, 0, greyCanvas.width, greyCanvas.height);
const greySquarePayload = {
width: greyCanvas.width,
height: greyCanvas.height,
data: greySquareImageData.data
};
roadSignMissingImagePayload = greySquarePayload;
addOrReplaceImage(ROAD_SIGN_MISSING_ICON, greySquarePayload);
const panelTextBgCanvas = document.createElement('canvas');
panelTextBgCanvas.width = 32;
panelTextBgCanvas.height = 32;
const panelTextBgCtx = panelTextBgCanvas.getContext('2d');
panelTextBgCtx.fillStyle = '#ffffff';
panelTextBgCtx.fillRect(0, 0, panelTextBgCanvas.width, panelTextBgCanvas.height);
panelTextBgCtx.strokeStyle = '#111111';
panelTextBgCtx.lineWidth = 1;
panelTextBgCtx.strokeRect(1, 1, panelTextBgCanvas.width - 1, panelTextBgCanvas.height - 1);
addOrReplaceImage('panel-text-background', {
width: panelTextBgCanvas.width,
height: panelTextBgCanvas.height,
data: panelTextBgCtx.getImageData(0, 0, panelTextBgCanvas.width, panelTextBgCanvas.height).data
});
// Load each known road sign SVG
roadSignTypes.forEach(signCode => {
const imageUrlSvg = `${baseUrl}${signCode}.svg`;
const imageUrlPng = `${baseUrl}${signCode}.png`;
const img = new Image(64, 64);
// Normalize icon name to lowercase for case-insensitive matching
const iconName = `road-sign-${signCode.toLowerCase()}`;
addOrReplaceImage(iconName, greySquarePayload);
let triedPng = false;
img.onload = () => {
window.panelRoadSignSpriteMetrics[iconName] = {
width: img.width,
height: img.height,
};
addOrReplaceImage(iconName, img);
};
img.onerror = () => {
if (!triedPng) {
triedPng = true;
img.src = imageUrlPng;
return;
}
console.warn(`Failed to load road sign image: ${signCode}`);
addOrReplaceImage(iconName, greySquarePayload);
};
img.src = imageUrlSvg;
});
// Create orientation arrow image
const arrowCanvas = document.createElement('canvas');
arrowCanvas.width = 32;
arrowCanvas.height = 32;
const arrowCtx = arrowCanvas.getContext('2d');
// Draw an arrow pointing up
arrowCtx.fillStyle = '#aaaaaa';
arrowCtx.strokeStyle = '#222222';
arrowCtx.lineWidth = 2;
arrowCtx.beginPath();
arrowCtx.moveTo(16, 4); // Top point
arrowCtx.lineTo(26, 18); // Right point
arrowCtx.lineTo(19, 18); // Right inner
arrowCtx.lineTo(19, 28); // Right bottom
arrowCtx.lineTo(13, 28); // Left bottom
arrowCtx.lineTo(13, 18); // Left inner
arrowCtx.lineTo(6, 18); // Left point
arrowCtx.closePath();
arrowCtx.fill();
arrowCtx.stroke();
addOrReplaceImage('orientation-arrow', {
width: arrowCanvas.width,
height: arrowCanvas.height,
data: arrowCtx.getImageData(0, 0, arrowCanvas.width, arrowCanvas.height).data
});
// Create vertical pole image (thin vertical line for pole representation)
const poleCanvas = document.createElement('canvas');
poleCanvas.width = 16;
poleCanvas.height = 64;
const poleCtx = poleCanvas.getContext('2d');
poleCtx.clearRect(0, 0, poleCanvas.width, poleCanvas.height);
poleCtx.fillStyle = '#555555';
poleCtx.strokeStyle = '#222222';
poleCtx.lineWidth = 1;
// Draw a rounded vertical rectangle centered horizontally
const poleWidth = 4;
const poleX = Math.floor((poleCanvas.width - poleWidth) / 2);
const poleY = 0;
const poleH = poleCanvas.height;
poleCtx.beginPath();
poleCtx.moveTo(poleX + poleWidth, poleY);
poleCtx.lineTo(poleX + poleWidth, poleY + poleH);
poleCtx.lineTo(poleX, poleY + poleH);
poleCtx.lineTo(poleX, poleY);
poleCtx.closePath();
poleCtx.fill();
poleCtx.stroke();
addOrReplaceImage('sign-pole-vertical', {
width: poleCanvas.width,
height: poleCanvas.height,
data: poleCtx.getImageData(0, 0, poleCanvas.width, poleCanvas.height).data
});
if (!map.hasImage('road-sign-images-loaded')) {
map.addImage('road-sign-images-loaded', {
width: 1,
height: 1,
data: new Uint8Array([0, 0, 0, 0])
});
}
}
const panelTextSpriteDefinitions = new Map();
const renderedPanelTextSprites = new Map();
const panelTextSpriteQueue = new Set();
let panelTextSpriteMap = null;
// Cache Image elements for road sign icons used inside Zone panels
const roadSignImageElements = window.roadSignImageElements || (window.roadSignImageElements = {});
// Sprites waiting for an icon to load
const iconWaiters = new Map(); // iconName -> Set(spriteId)
function registerIconWaiter(iconName, spriteId) {
if (!iconName || !spriteId) return;
let set = iconWaiters.get(iconName);
if (!set) {
set = new Set();
iconWaiters.set(iconName, set);
}
set.add(spriteId);
}
function notifyIconLoaded(iconName) {
const set = iconWaiters.get(iconName);
if (!set) return;
iconWaiters.delete(iconName);
const ids = Array.from(set.values());
ids.forEach(id => ensurePanelTextSprite(panelTextSpriteMap, id));
}
function preloadZoneIcon(remainderCodeOriginal) {
if (!remainderCodeOriginal) return;
const cleaned = remainderCodeOriginal.replace(/[’']/g, '');
const iconName = `road-sign-${cleaned.toLowerCase()}`;
if (roadSignImageElements[iconName]) return;
const base = "{% static 'assets/road_signs/2025/' %}";
// Try original case SVG, then lower, then upper, then PNG variants
const candidates = [
`${base}${cleaned}.svg`,
`${base}${cleaned.toLowerCase()}.svg`,
`${base}${cleaned.toUpperCase()}.svg`,
`${base}${cleaned}.png`,
`${base}${cleaned.toLowerCase()}.png`,
`${base}${cleaned.toUpperCase()}.png`,
];
let idx = 0;
const attempt = () => {
if (idx >= candidates.length) return;
const url = candidates[idx++];
const img = new Image(64, 64);
img.crossOrigin = 'anonymous';
img.onload = () => { roadSignImageElements[iconName] = img; notifyIconLoaded(iconName); };
img.onerror = attempt;
img.src = url;
};
attempt();
}
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
// Load symbol SVGs for panel text rendering
const symbolImages = {};
const whiteSymbolImages = {};
function loadSymbolImages() {
const symbolsBaseUrl = "{% static 'assets/road_signs/' %}";
const symbols = {
'pedestrian': 'pedestrian.svg',
'car': 'car_side.svg',
'bike': 'bike.svg'
};
Object.keys(symbols).forEach(key => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
symbolImages[key] = img;
// Create a white version
const canvas = document.createElement('canvas');
canvas.width = img.width || 100;
canvas.height = img.height || 100;
const ctx = canvas.getContext('2d');
// Draw original image
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
// Get image data and make it white while preserving alpha
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
if (data[i + 3] > 0) { // If not transparent
data[i] = 255; // R
data[i + 1] = 255; // G
data[i + 2] = 255; // B
}
}
ctx.putImageData(imageData, 0, 0);
// Store as white version
const whiteImg = new Image();
whiteImg.onload = () => {
whiteSymbolImages[key] = whiteImg;
console.log(`Symbol loaded (white version): ${key}`);
};
whiteImg.src = canvas.toDataURL();
console.log(`Symbol loaded: ${key}`);
};
img.onerror = () => {
console.warn(`Failed to load symbol: ${key}`);
};
img.src = `${symbolsBaseUrl}${symbols[key]}`;
});
}
// Call this function early to start loading symbols
loadSymbolImages();
function normalizePanelText(raw, panelType) {
const panelTypeUpper = panelType ? panelType.toUpperCase() : '';
// Z* panels: prepend ZONE header; underlying sign is embedded separately
if (panelTypeUpper.startsWith('Z')) {
let text = (raw ?? '').toString()
.replace(/<br\s*\/??\s*>/gi, '\n')
.replace(/[|]/g, '\n')
.trim();
// Remove any provided ZONE lines to avoid duplicates
const remainder = panelTypeUpper.slice(1).replace(/[’']/g, '');
const filtered = text
.split(/\r?\n/)
.map(s => s.trim())
.filter(Boolean)
.filter(s => s.toUpperCase() !== 'ZONE' && s.toUpperCase() !== remainder.toUpperCase());
const body = filtered.join('\n');
return body ? `ZONE\n${body}` : 'ZONE';
}
// TYPEVE always shows fixed text
if (panelTypeUpper === 'TYPEVE') {
return 'PAYANT\nBETALEND';
}
// TYPE VIIDE: blue rectangle with fixed text
if (panelTypeUpper === 'TYPE VIIDE' || panelTypeUpper === 'TYPEVIIDE') {
return 'C. D.';
}
// TYPEXIVF: Fixed text for electric vehicle charging
if (panelTypeUpper === 'TYPEXIVF') {
return 'Forfait 50€ / 4h30\nExcepté\nUitgezonderd\nVéhicule en charge\nOpladend voertuig';
}
// ICR: green bike route panel => bike symbol + ICR
if (panelTypeUpper === 'ICR') {
return '\u0001BIKE\u0002\nICR';
}
// FXX arrows-only when no text
if ((raw === null || raw === undefined || raw.toString().trim() === '') && panelTypeUpper === 'FXX') {
return '⟵\n⟶';
}
if (raw === null || raw === undefined) {
return '';
}
// FXX special case: "AXE ROUGE" panels -> fixed red text
const rawStr = raw.toString();
if (panelTypeUpper === 'FXX' && /^\s*AXE\s+ROUGE/i.test(rawStr)) {
return 'AXE ROUGE\nRODE AS';
}
// XC panels: text value + m + up arrow
if (panelTypeUpper === 'XC') {
const text = raw.toString().trim();
return `${text}\nm\n↑`;
}
let normalized = raw
.toString()
.replace(/<br\s*\/?\s*>/gi, '\n')
.replace(/\bS153\b/gi, '\u0001PEDESTRIAN\u0002') // S153 -> pedestrian (word boundary)
.replace(/\bS136\b/gi, '\u0001CAR\u0002') // S136 -> car (word boundary)
.replace(/\bS307\b/g, ' ') // S307 -> space
.replace(/\bS309\b/g, ' ') // S309 -> space
.replace(/[|;]+/g, ' ') // | and ; -> space (keep symbols on same line)
.replace(/\bS162b\b/g, '→') // S162b -> right arrow
.replace(/\bS301\b/g, 'P') // S301 -> P symbol
.replace(/s305 60A‚A/g, 'P')
.split(/\r?\n/)
.map(part => part.replace(/\s+/g, ' ').trim())
.filter(Boolean)
.join('\n');
// For FXX panels, prepend left and right arrows (longer arrows)
if (panelTypeUpper === 'FXX') {
const arrows = '⟵\n⟶'; // Longer arrows
normalized = normalized ? `${arrows}\n${normalized}` : arrows;
}
return normalized;
}
function drawRoundedPanel(ctx, x, y, width, height, radius) {
const r = Math.min(radius, width / 2, height / 2);
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + width - r, y);
ctx.quadraticCurveTo(x + width, y, x + width, y + r);
ctx.lineTo(x + width, y + height - r);
ctx.quadraticCurveTo(x + width, y + height, x + width - r, y + height);
ctx.lineTo(x + r, y + height);
ctx.quadraticCurveTo(x, y + height, x, y + height - r);
ctx.lineTo(x, y + r);
ctx.quadraticCurveTo(x, y, x + r, y);
ctx.closePath();
}
// Draw a directional panel (rectangle with a triangular arrow tip on the right)
function drawDirectionalPanel(ctx, x, y, width, height, radius, tipWidth = 18, direction = 'right') {
const r = Math.min(radius, width / 2, height / 2);
const tip = Math.min(tipWidth, Math.max(12, Math.floor(width * 0.18)));
// Base rectangle width before the tip
const rectWidth = width - tip;
ctx.beginPath();
if (direction === 'right') {
// Top edge with rounded corner
ctx.moveTo(x + r, y);
ctx.lineTo(x + rectWidth, y);
// Right arrow point matching the white/brown arrow angle (0.1 to 0.9 of height)
ctx.lineTo(x + rectWidth, y + (height * 0));
ctx.lineTo(x + width, y + height / 2);
ctx.lineTo(x + rectWidth, y + (height * 1));
// Bottom edge
ctx.lineTo(x + rectWidth, y + height);
ctx.lineTo(x + r, y + height);
ctx.quadraticCurveTo(x, y + height, x, y + height - r);
ctx.lineTo(x, y + r);
ctx.quadraticCurveTo(x, y, x + r, y);
} else {
// Left-pointing arrow version
ctx.moveTo(x + tip, y);
ctx.lineTo(x + width - r, y);
ctx.quadraticCurveTo(x + width, y, x + width, y + r);
ctx.lineTo(x + width, y + height - r);
ctx.quadraticCurveTo(x + width, y + height, x + width - r, y + height);
ctx.lineTo(x + tip, y + height);
// Left arrow point matching the white/brown arrow angle (0.1 to 0.9 of height)
ctx.lineTo(x + tip, y + (height * 0));
ctx.lineTo(x, y + height / 2);
ctx.lineTo(x + tip, y + (height * 1));
ctx.lineTo(x + tip, y);
}
ctx.closePath();
}
function buildPanelTextCanvasPayload(lines, options = {}) {
if (!Array.isArray(lines) || lines.length === 0) {
return null;
}
// Default options for white panels (F34A style)
const bgColor = options.bgColor || '#ffffff';
const textColor = options.textColor || '#111111';
const borderColor = options.borderColor || '#111111';
const borderWidth = options.borderWidth !== undefined ? options.borderWidth : 3;
const isArrowPanel = options.isArrowPanel || false;
const scaleFactor = 1.0; // Override to avoid double-scaling by MapLibre icon-size
const widthCm = Number(options.widthCm || 40);
const heightCm = Number(options.heightCm || widthCm);
const baseWidthPx = 76;
const paddingX = 10;
const paddingY = 5;
// Width configuration
let minWidth = 140;
let maxWidth = 400;
if (options.vertical) {
minWidth = 30; // Very narrow for XC and TYPEXIVF vertical panels
maxWidth = 200; // Allow wider for TYPEXIVF with more text
}
if (options.narrow) {
minWidth = Math.min(minWidth, 100); // Narrow min width for TYPE*/FXX
}
// When dimensions are available (or defaulted to 40cm), keep a consistent visual width
// across panel types for the same physical width.
const widthScale = Math.max(0.2, widthCm / 40);
const targetWidth = Math.round(baseWidthPx * widthScale);
minWidth = targetWidth;
maxWidth = targetWidth;
const longestLength = Math.max(...lines.map(line => line.length));
const baseFontSize = 14;
const scaledBaseFontSize = baseFontSize * scaleFactor;
const measurerCanvas = document.createElement('canvas');
const measurer = measurerCanvas.getContext('2d');
if (!measurer) {
return null;
}
// Symbol markers: \u0001PEDESTRIAN\u0002, \u0001CAR\u0002, \u0001BIKE\u0002
const SYMBOL_REGEX = /(\u0001PEDESTRIAN\u0002|\u0001CAR\u0002|\u0001BIKE\u0002)/g;
const PEDESTRIAN_MARKER = '\u0001PEDESTRIAN\u0002';
const CAR_MARKER = '\u0001CAR\u0002';
const BIKE_MARKER = '\u0001BIKE\u0002';
function measureMaxLineWidth(currentFontSize) {
const fontFamily = `700 ${currentFontSize}px 'Open Sans','Arial','Helvetica',sans-serif`;
measurer.font = fontFamily;
let maxWidth = 0;
lines.forEach(line => {
if (line.includes(PEDESTRIAN_MARKER) || line.includes(CAR_MARKER) || line.includes(BIKE_MARKER)) {
const parts = line.split(SYMBOL_REGEX);
let lineWidth = 0;
parts.forEach(part => {
if (part === PEDESTRIAN_MARKER || part === CAR_MARKER || part === BIKE_MARKER) {
lineWidth += currentFontSize * 1.5;
} else if (part) {
lineWidth += measurer.measureText(part).width;
}
});
maxWidth = Math.max(maxWidth, lineWidth);
} else {
maxWidth = Math.max(maxWidth, measurer.measureText(line).width || 0);
}
});
return Math.max(maxWidth, 1);
}
// Add extra padding based on panel type
let extraPadding = 10; // Default
if (options.directional) {
extraPadding = 40; // Directional panels need more space
} else if (options.vertical) {
extraPadding = 5; // Vertical panels (XC, TYPEXIVF) minimal padding
}
const displayWidth = targetWidth;
const availableTextWidth = Math.max(8, displayWidth - (paddingX * 2 + extraPadding));
let fontSize = clamp(
scaledBaseFontSize - Math.max(0, longestLength - 12) * 1.05 * scaleFactor,
scaledBaseFontSize * 0.5,
30 * scaleFactor
);
const minFontSize = Math.max(8 * scaleFactor, scaledBaseFontSize * 0.4);
let measuredWidth = measureMaxLineWidth(fontSize);
while (measuredWidth > availableTextWidth && fontSize > minFontSize) {
fontSize -= 0.5 * scaleFactor;
measuredWidth = measureMaxLineWidth(fontSize);
}
const fontFamily = `700 ${fontSize}px 'Open Sans','Arial','Helvetica',sans-serif`;
measurer.font = fontFamily;
// Reduce line height for arrow lines in FXX panels
const baseLineHeight = fontSize + 10;
// Calculate height - all lines use same height calculation
let totalTextHeight = 0;
lines.forEach((line, index) => {
const isArrowLine = isArrowPanel && index < 2;
const lineHeight = isArrowLine ? (fontSize + 2) : baseLineHeight;
totalTextHeight += lineHeight;
});
const idealHeight = Math.round(displayWidth * (heightCm / widthCm));
const totalHeight = paddingY * 2 + totalTextHeight;
const displayHeight = Math.ceil(Math.max(totalHeight, idealHeight));
const pixelRatio = clamp(window.devicePixelRatio || 1, 1, 2.5);
const canvas = document.createElement('canvas');
const bitmapWidth = Math.ceil(displayWidth * pixelRatio);
const bitmapHeight = Math.ceil(displayHeight * pixelRatio);
canvas.width = bitmapWidth;
canvas.height = bitmapHeight;
const ctx = canvas.getContext('2d');
if (!ctx) {
return null;
}
ctx.scale(pixelRatio, pixelRatio);
ctx.font = fontFamily;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.fillStyle = bgColor;
ctx.strokeStyle = borderColor;
ctx.lineWidth = borderWidth;
if (options.directional) {
drawDirectionalPanel(ctx, 1.5, 1.5, displayWidth - 3, displayHeight - 3, 12, options.tipWidth || 18, options.direction || 'right');
ctx.fill();
if (borderWidth > 0) {
ctx.stroke();
}
// Fill the arrow tip interior with white
ctx.fillStyle = '#ffffff';
const x = 1.5, y = 1.5, width = displayWidth - 3, height = displayHeight - 3;
const tip = Math.min((options.tipWidth || 18), Math.max(12, Math.floor(width * 0.18)));
const rectWidth = width - tip;
const offsetX = 0; // Shift more to the left to hide rounded edge
if (options.direction === 'right') {
ctx.beginPath();
ctx.moveTo(x + rectWidth + offsetX, y + (height * 0));
ctx.lineTo(x + width + offsetX, y + height / 2);
ctx.lineTo(x + rectWidth + offsetX, y + (height * 1));
ctx.closePath();
ctx.fill();
// Draw a narrower brown arrow on top
ctx.fillStyle = bgColor;
const narrowTip = tip * 0.55; // Wider brown arrow
const inset = (height * 0.8) * 0; // Less inset = taller arrow
ctx.beginPath();
ctx.moveTo(x + rectWidth + offsetX, y + (height * 0) + inset);
ctx.lineTo(x + rectWidth + narrowTip + offsetX, y + height / 2);
ctx.lineTo(x + rectWidth + offsetX, y + (height * 1) - inset);
ctx.closePath();
ctx.fill();
} else {
const offsetXLeft = 6; // Shift more to the right to hide rounded edge
ctx.beginPath();
ctx.moveTo(x + tip + offsetXLeft, y + (height * 0));
ctx.lineTo(x + offsetXLeft, y + height / 2);
ctx.lineTo(x + tip + offsetXLeft, y + (height * 1));
ctx.closePath();
ctx.fill();
// Draw a narrower brown arrow on top
ctx.fillStyle = bgColor;
const narrowTip = tip * 0.55; // Wider brown arrow
const inset = (height * 0.8) * 0.12; // Less inset = taller arrow
ctx.beginPath();
ctx.moveTo(x + tip + offsetXLeft, y + (height * 0) + inset);
ctx.lineTo(x + tip - narrowTip + offsetXLeft, y + height / 2);
ctx.lineTo(x + tip + offsetXLeft, y + (height * 1) - inset);
ctx.closePath();
ctx.fill();
}
} else {
drawRoundedPanel(ctx, 1.5, 1.5, displayWidth - 3, displayHeight - 3, 12);
ctx.fill();
if (borderWidth > 0) {
ctx.stroke();
}
}
ctx.fillStyle = textColor;
// Vertically center the entire text block inside the displayHeight
let currentY = (displayHeight - totalTextHeight) / 2;
lines.forEach((line, index) => {
const isArrowLine = isArrowPanel && index < 2;
const lineHeight = isArrowLine ? (fontSize + 2) : baseLineHeight;
currentY += lineHeight / 2;
// Check if line contains symbol placeholders
if (line.includes(PEDESTRIAN_MARKER) || line.includes(CAR_MARKER) || line.includes(BIKE_MARKER)) {
// Split line and render text + symbols inline
const parts = line.split(SYMBOL_REGEX);
let currentX = 0;
// First measure total width
let totalWidth = 0;
parts.forEach(part => {
if (part === PEDESTRIAN_MARKER || part === CAR_MARKER || part === BIKE_MARKER) {
totalWidth += fontSize * 1.5;
} else if (part) {
totalWidth += measurer.measureText(part).width;
}
});
currentX = (displayWidth - totalWidth) / 2;
// Render each part
parts.forEach(part => {
if (part === PEDESTRIAN_MARKER) {
const symbolImg = (textColor === '#ffffff') ? whiteSymbolImages.pedestrian : symbolImages.pedestrian;
if (symbolImg) {
const symbolSize = fontSize * 1.5;
ctx.drawImage(symbolImg, currentX, currentY - symbolSize / 2, symbolSize, symbolSize);
currentX += symbolSize;
} else {
console.warn('Pedestrian symbol not loaded yet');
}
} else if (part === CAR_MARKER) {
const symbolImg = (textColor === '#ffffff') ? whiteSymbolImages.car : symbolImages.car;
if (symbolImg) {
const symbolSize = fontSize * 1.5;
ctx.drawImage(symbolImg, currentX, currentY - symbolSize / 2, symbolSize, symbolSize);
currentX += symbolSize;
} else {
console.warn('Car symbol not loaded yet');
}
} else if (part === BIKE_MARKER) {
const symbolImg = (textColor === '#ffffff') ? whiteSymbolImages.bike : symbolImages.bike;
if (symbolImg) {
const symbolSize = fontSize * 1.5;
ctx.drawImage(symbolImg, currentX, currentY - symbolSize / 2, symbolSize, symbolSize);
currentX += symbolSize;
} else {
console.warn('Bike symbol not loaded yet');
}
} else if (part) {
ctx.textAlign = 'left';
ctx.fillStyle = textColor;
ctx.fillText(part, currentX, currentY);
currentX += measurer.measureText(part).width;
}
});
ctx.textAlign = 'center';
} else {
// Normal text rendering
ctx.fillStyle = textColor;
ctx.fillText(line, displayWidth / 2, currentY);
}
currentY += lineHeight / 2;
});
const imageData = ctx.getImageData(0, 0, bitmapWidth, bitmapHeight);
return {
width: bitmapWidth,
height: bitmapHeight,
data: imageData.data,
pixelRatio,
};
}
// Dedicated payload builder for Zone panels with embedded sign icon
function buildPanelTextCanvasPayloadZone(lines, options = {}) {
if (!Array.isArray(lines) || lines.length === 0) return null;
const bgColor = options.bgColor || '#ffffff';
const textColor = options.textColor || '#111111';
const borderColor = options.borderColor || '#111111';
const borderWidth = options.borderWidth !== undefined ? options.borderWidth : 3;
const scaleFactor = 1.0; // Override to avoid double-scaling by MapLibre icon-size
const widthCm = Number(options.widthCm || 40);
const heightCm = Number(options.heightCm || widthCm);
const baseWidthPx = 72;
const paddingX = 10, paddingY = 5;
const baseFontSize = 14;
let fontSize = baseFontSize * scaleFactor;
let fontFamily = `700 ${fontSize}px 'Open Sans','Arial','Helvetica',sans-serif`;
const measurerCanvas = document.createElement('canvas');
const measurer = measurerCanvas.getContext('2d');
if (!measurer) return null;
measurer.font = fontFamily;
// embedded icon
let iconW = 64 * scaleFactor, iconH = (options.signIconHeight || 64) * scaleFactor;
const img = options.signIconName ? roadSignImageElements[options.signIconName] : null;
if (img && img.width && img.height) {
const ratio = img.width / img.height;
iconW = Math.round(iconH * ratio);
}
const widthScale = Math.max(0.2, widthCm / 40);
const targetWidth = Math.round(baseWidthPx * widthScale);
const availableTextWidth = Math.max(8, targetWidth - (paddingX * 2));
let measuredWidth = 0;
lines.forEach(line => { measuredWidth = Math.max(measuredWidth, measurer.measureText(line).width); });
const minFontSize = Math.max(8 * scaleFactor, baseFontSize * 0.45 * scaleFactor);
while (measuredWidth > availableTextWidth && fontSize > minFontSize) {
fontSize -= 0.5 * scaleFactor;
fontFamily = `700 ${fontSize}px 'Open Sans','Arial','Helvetica',sans-serif`;
measurer.font = fontFamily;
measuredWidth = 0;
lines.forEach(line => { measuredWidth = Math.max(measuredWidth, measurer.measureText(line).width); });
}
const displayWidth = Math.max(targetWidth, Math.ceil(paddingX * 2 + Math.max(measuredWidth, iconW)));
const lineHeight = fontSize + 10;
// total height: all lines + icon between first and the rest
const totalContentHeight = lines.length * lineHeight + iconH + 6;
const idealHeight = Math.round(displayWidth * (heightCm / widthCm));
const totalHeight = Math.ceil(Math.max(paddingY * 2 + totalContentHeight, idealHeight));
const pixelRatio = Math.min(Math.max(window.devicePixelRatio || 1, 1), 2.5);
const canvas = document.createElement('canvas');
const bitmapWidth = Math.ceil(displayWidth * pixelRatio);
const bitmapHeight = Math.ceil(totalHeight * pixelRatio);
canvas.width = bitmapWidth; canvas.height = bitmapHeight;
const ctx = canvas.getContext('2d');
if (!ctx) return null;
ctx.scale(pixelRatio, pixelRatio);
ctx.font = fontFamily; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
// draw panel background
ctx.fillStyle = bgColor; ctx.strokeStyle = borderColor; ctx.lineWidth = borderWidth;
drawRoundedPanel(ctx, 1.5, 1.5, displayWidth - 3, totalHeight - 3, 12);
ctx.fill(); if (borderWidth > 0) ctx.stroke();
// Vertically center the entire content block in totalHeight
let currentY = (totalHeight - totalContentHeight) / 2;
// draw first line
ctx.fillStyle = textColor;
ctx.fillText(lines[0], displayWidth / 2, currentY + lineHeight / 2);
currentY += lineHeight;
// draw embedded icon centered
const drawW = iconW, drawH = iconH;
const imgX = Math.round((displayWidth - drawW) / 2);
const imgY = Math.round(currentY);
if (img) {
// Optionally render icon in grayscale for apostrophe variants
const prevFilter = ctx.filter;
ctx.filter = options.grayIcon ? 'grayscale(100%)' : 'none';
ctx.drawImage(img, imgX, imgY, drawW, drawH);
ctx.filter = prevFilter || 'none';
} else {
ctx.fillStyle = '#d0d0d0';
drawRoundedPanel(ctx, imgX, imgY, drawW, drawH, 6);
ctx.fill();
ctx.strokeStyle = '#7a7a7a'; ctx.lineWidth = 1.5; ctx.stroke();
ctx.fillStyle = textColor;
}
currentY += drawH + 6;
// draw remaining lines
for (let i = 1; i < lines.length; i++) {
ctx.fillText(lines[i], displayWidth / 2, currentY + lineHeight / 2);
currentY += lineHeight;
}
// Add a large black diagonal strike for apostrophe variants
if (options.strike) {
const margin = 8;
ctx.strokeStyle = '#000000';
ctx.lineWidth = 10;
ctx.beginPath();
ctx.moveTo(margin, totalHeight - margin);
ctx.lineTo(displayWidth - margin, margin);
ctx.stroke();
}
const imageData = ctx.getImageData(0, 0, bitmapWidth, bitmapHeight);
return { width: bitmapWidth, height: bitmapHeight, data: imageData.data, pixelRatio };
}
function ensurePanelTextSprite(map, spriteId) {
if (!map || !spriteId || !panelTextSpriteDefinitions.has(spriteId)) {
if (spriteId && (spriteId.startsWith('panel-text-f34a-') || spriteId.startsWith('panel-text-type') || spriteId.startsWith('panel-text-fxx-') || spriteId.startsWith('panel-text-icr-'))) {
console.warn('Aucune définition trouvée pour', spriteId);
}
return;
}
const definition = panelTextSpriteDefinitions.get(spriteId);
if (!definition) {
return;
}
// If sprite depends on a Zone icon not yet loaded, wait and retry later
if (definition.style && definition.style.signIconName) {
const iconName = definition.style.signIconName;
if (!roadSignImageElements[iconName]) {
registerIconWaiter(iconName, spriteId);
return;
}
}
const currentSignature = renderedPanelTextSprites.get(spriteId);
if (currentSignature === definition.textKey && map.hasImage(spriteId)) {
return;
}
const payload = (definition.style && definition.style.signIconName)
? buildPanelTextCanvasPayloadZone(definition.lines, definition.style)
: buildPanelTextCanvasPayload(definition.lines, definition.style);
if (!payload) {
return;
}
const imagePayload = {
width: payload.width,
height: payload.height,
data: payload.data,
};
const imageOptions = { pixelRatio: payload.pixelRatio || 1 };
if (map.hasImage(spriteId)) {
map.updateImage(spriteId, imagePayload);
} else {
map.addImage(spriteId, imagePayload, imageOptions);
}
const displayPixelRatio = payload.pixelRatio || 1;
window.panelTextSpriteMetrics[spriteId] = {
width: payload.width / displayPixelRatio,
height: payload.height / displayPixelRatio,
};
window.dispatchEvent(new CustomEvent('panelTextSpriteRendered', {
detail: {
spriteId,
width: window.panelTextSpriteMetrics[spriteId].width,
height: window.panelTextSpriteMetrics[spriteId].height,
}
}));
renderedPanelTextSprites.set(spriteId, definition.textKey);
}
function flushPanelTextSpriteQueue() {
if (!panelTextSpriteMap || panelTextSpriteQueue.size === 0) {
return;
}
const ids = Array.from(panelTextSpriteQueue.values());
panelTextSpriteQueue.clear();
ids.forEach(id => ensurePanelTextSprite(panelTextSpriteMap, id));
}
function registerPanelTextDefinitionFromProperties(props) {
if (!props || !props.panel_text_can_render || !props.panel_text_image_id) {
return null;
}
const panelType = props.panel_type || '';
const panelTypeUpper = panelType.toUpperCase();
const widthCm = Number(props.panel_width_cm || 50);
const heightCm = Number(props.panel_height_cm || widthCm);
const scaleFactor = props.panel_scale_factor || 1.0;
// TYPEVE, TYPEXIVF, TYPE VIIDE and FXX (arrows-only) don't need panel_text property
if (!props.panel_text && panelTypeUpper !== 'TYPEVE' && panelTypeUpper !== 'TYPEXIVF'
&& panelTypeUpper !== 'FXX' && panelTypeUpper !== 'TYPE VIIDE' && panelTypeUpper !== 'TYPEVIIDE' && panelTypeUpper !== 'ICR') {
return null;
}
const normalized = normalizePanelText(props.panel_text, panelType);
if (!normalized) {
return null;
}
const lines = normalized.split('\n');
const existing = panelTextSpriteDefinitions.get(props.panel_text_image_id);
if (existing && existing.textKey === normalized) {
return props.panel_text_image_id;
}
// Determine panel style based on panel_type
let style = {}; // Default: white background (F34A style)
// TYPEVE: Blue panel with fixed text PAYANT/BETALEND
if (panelTypeUpper === 'TYPEVE') {
style = {
bgColor: '#0000FF', // Blue background
textColor: '#ffffff', // White text
borderColor: '#ffffff', // White border
borderWidth: 2
};
}
// TYPE VIIDE: Blue rectangle with fixed text C. D.
else if (panelTypeUpper === 'TYPE VIIDE' || panelTypeUpper === 'TYPEVIIDE') {
style = {
bgColor: '#0000FF',
textColor: '#ffffff',
borderColor: '#ffffff',
borderWidth: 2,
narrow: true
};
}
// XC: White vertical panel with text + m + up arrow
else if (panelTypeUpper === 'XC') {
style = {
bgColor: '#ffffff', // White background
textColor: '#111111', // Black text
borderColor: '#111111', // Black border
borderWidth: 3,
vertical: true // Vertical orientation
};
}
// TYPEXIVF: White vertical panel with charging info
else if (panelTypeUpper === 'TYPEXIVF') {
style = {
bgColor: '#ffffff', // White background
textColor: '#111111', // Black text
borderColor: '#111111', // Black border
borderWidth: 3,
vertical: true // Vertical orientation (not minimal)
};
}
// Blue rectangular panels (TYPE* series and FXX)
else if (panelTypeUpper.startsWith('Z')) {
// Zone panels: white rectangle, black text, and embedded sign icon (e.g., ZE9a -> E9a)
const rawType = (props.panel_type || '').toString();
const remainderOriginal = rawType.slice(1);
const cleanedRemainder = remainderOriginal.replace(/[’']/g, '');
const iconName = `road-sign-${cleanedRemainder.toLowerCase()}`;
preloadZoneIcon(remainderOriginal);
registerIconWaiter(iconName, props.panel_text_image_id);
const hasApostrophe = /[’']\s*$/.test(rawType);
style = {
bgColor: '#ffffff',
textColor: '#111111',
borderColor: '#111111',
borderWidth: 3,
signIconName: iconName,
signIconHeight: 64,
grayIcon: hasApostrophe,
strike: hasApostrophe,
};
}
else if (panelTypeUpper.startsWith('TYPE') || panelTypeUpper === 'FXX') {
const isAxeRouge = normalized.startsWith('AXE ROUGE\nRODE AS');
if (panelTypeUpper === 'FXX' && isAxeRouge) {
// Red variant for AXE ROUGE panels
style = {
bgColor: '#c00000', // Red background
textColor: '#ffffff', // White text
borderColor: '#ffffff',
borderWidth: 2,
isArrowPanel: false,
narrow: true
};
} else {
style = {
bgColor: '#0000FF', // Blue background (same as F23a SVG)
textColor: '#ffffff', // White text
borderColor: '#ffffff', // White border
borderWidth: 2,
isArrowPanel: panelTypeUpper === 'FXX', // Flag for FXX panels with arrows
narrow: true // Allow narrower width adapting to text
};
}
} else if (panelTypeUpper === 'ICR') {
style = {
bgColor: '#1f8b3b', // Green background
textColor: '#ffffff', // White text
borderColor: '#ffffff', // White border
borderWidth: 2,
narrow: true
};
} else if (panelTypeUpper === 'F33C' || panelTypeUpper === 'F35') {
// Brown directional panel with arrow tip
// Determine arrow direction from orientation (degrees), default to 'right'
let computedDirection = 'right';
const orientationDeg = Number(props.orientation);
if (!Number.isNaN(orientationDeg)) {
const a = ((orientationDeg % 360) + 360) % 360; // normalize 0..359
// Tolerance band: choose left for angles within [80°, 280°], else right
const LEFT_START = 80;
const LEFT_END = 280;
computedDirection = (a >= LEFT_START && a <= LEFT_END) ? 'left' : 'right';
}
style = {
bgColor: '#8B5A2B', // Brown background (tourism signs style)
textColor: '#ffffff', // White text
borderColor: '#ffffff', // White border
borderWidth: 2,
directional: true, // Use directional arrow shape
tipWidth: 20, // Width of arrow tip
direction: computedDirection
};
}
style.widthCm = widthCm;
style.heightCm = heightCm;
style.scaleFactor = scaleFactor;
panelTextSpriteDefinitions.set(props.panel_text_image_id, {
lines,
textKey: normalized,
style,
});
panelTextSpriteQueue.add(props.panel_text_image_id);
return props.panel_text_image_id;
}
function enqueuePanelTextSprites(features) {
if (!Array.isArray(features)) {
return;
}
features.forEach(feature => {
registerPanelTextDefinitionFromProperties(feature?.properties);
});
flushPanelTextSpriteQueue();
}
function ensurePanelTextDefinitionFromMap(map, spriteId) {
if (!map || !spriteId) {
return false;
}
if (panelTextSpriteDefinitions.has(spriteId)) {
return true;
}
let features = [];
try {
if (typeof map.querySourceFeatures === 'function') {
features = map.querySourceFeatures('assets', {
filter: ['==', ['get', 'panel_text_image_id'], spriteId]
}) || [];
}
} catch (err) {
console.warn('Impossible de récupérer la définition du panneau via querySourceFeatures', err);
return false;
}
if (!features.length) {
return false;
}
registerPanelTextDefinitionFromProperties(features[0]?.properties);
return panelTextSpriteDefinitions.has(spriteId);
}
if (typeof assetsgeojsonloader !== 'undefined') {
assetsgeojsonloader.addEventListener('geojsonLoaded', (event) => {
const features = event.detail?.features || [];
calculatePanelOffsets(features);
enqueuePanelTextSprites(features);
});
}
if (typeof assets_geojson !== 'undefined' && Array.isArray(assets_geojson.features)) {
calculatePanelOffsets(assets_geojson.features);
enqueuePanelTextSprites(assets_geojson.features);
}
// Add map load event listener to load images
maploader.addEventListener("mapLoaded", (event) => {
const loadedMap = event.detail;
loadRoadSignImages(loadedMap);
panelTextSpriteMap = loadedMap;
flushPanelTextSpriteQueue();
loadedMap.on('styleimagemissing', (e) => {
const missingId = e.id;
if (!missingId) {
return;
}
if (missingId.startsWith('panel-text-f34a-') || missingId.startsWith('panel-text-type') || missingId.startsWith('panel-text-fxx-') || missingId.startsWith('panel-text-f33c-') || missingId.startsWith('panel-text-xc-') || missingId.startsWith('panel-text-typexivf-') || missingId.startsWith('panel-text-icr-') || missingId.startsWith('panel-text-zone-')) {
const hasDefinition = ensurePanelTextDefinitionFromMap(loadedMap, missingId);
if (!hasDefinition) {
console.warn('Aucune définition trouvée pour', missingId);
return;
}
ensurePanelTextSprite(loadedMap, missingId);
return;
}
if (!missingId.startsWith('road-sign-')) {
return;
}
if (!roadSignMissingImagePayload) {
return;
}
if (!loadedMap.hasImage(missingId)) {
loadedMap.addImage(missingId, roadSignMissingImagePayload);
}
});
});
</script>
{% endblock extra_script %}