1536 lines
63 KiB
HTML
1536 lines
63 KiB
HTML
{% 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 %}
|