1614 lines
68 KiB
JavaScript
1614 lines
68 KiB
JavaScript
/**
|
||
* Maps app – Concrete Blocks map
|
||
*
|
||
* Renders inventoried concrete-block assets on a MapLibre map.
|
||
* Points are styled with 1 / 2 / 3 side-by-side squares depending on the
|
||
* number of blocks (< 3 / 3–10 / > 10).
|
||
* Square colour reflects the asset function (pie split for multiple functions).
|
||
*/
|
||
|
||
'use strict';
|
||
|
||
/* i18n helpers – fall back gracefully if the JS catalog is not loaded */
|
||
const _gt = (typeof gettext !== 'undefined') ? gettext : (s => s);
|
||
const _ngt = (typeof ngettext !== 'undefined') ? ngettext : ((s, p, n) => n === 1 ? s : p);
|
||
const _ip = (typeof interpolate !== 'undefined') ? interpolate : ((fmt, obj) => fmt.replace(/%\(([^)]+)\)s/g, (_, k) => obj[k]));
|
||
|
||
/* ══════════════════════════════════════════════════════════════════════
|
||
Project status colours (mirrors projects/static/projects/projects_index.js)
|
||
══════════════════════════════════════════════════════════════════════ */
|
||
const NJ_PROJECT_STATUS_CONFIG = {
|
||
in_preparation: { color: '#6c757d', label: _gt('En préparation'), dark: false },
|
||
planned: { color: '#ffc107', label: _gt('Planifié'), dark: true },
|
||
in_progress: { color: '#007bff', label: _gt('En cours'), dark: false },
|
||
finished: { color: '#17a2b8', label: _gt('Terminé'), dark: false },
|
||
invoiced: { color: '#343a40', label: _gt('Comptabilisé'), dark: false },
|
||
closed: { color: '#343a40', label: _gt('Clôturé'), dark: false },
|
||
canceled: { color: '#6c757d', label: _gt('Annulé'), dark: false },
|
||
};
|
||
const NJ_DEFAULT_COLOR = '#888888';
|
||
|
||
/** Build a MapLibre match expression on status_key → fill/line colour. */
|
||
function buildNjColorExpr() {
|
||
const expr = ['match', ['get', 'status_key']];
|
||
for (const [key, cfg] of Object.entries(NJ_PROJECT_STATUS_CONFIG)) {
|
||
expr.push(key, cfg.color);
|
||
}
|
||
expr.push(NJ_DEFAULT_COLOR);
|
||
return expr;
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════════════
|
||
Map initialisation
|
||
══════════════════════════════════════════════════════════════════════ */
|
||
|
||
const cbMap = new maplibregl.Map({
|
||
container: 'cb-map',
|
||
style: window.MAP_STYLE_LIGHT,
|
||
center: [4.3572, 50.8477], // Brussels
|
||
zoom: 11,
|
||
});
|
||
|
||
let currentFeatures = []; // cache for the last loaded GeoJSON features
|
||
let _selectedFeature = null; // currently highlighted feature
|
||
const _fnKeyColorsCache = {}; // functions_key → colors[] (for styleimagemissing)
|
||
let _njProjects = null; // cache for the NJ projects list
|
||
|
||
/* ══════════════════════════════════════════════════════════════════════
|
||
Square icon helpers (symbology: 1 / 2 / 3 squares side by side)
|
||
══════════════════════════════════════════════════════════════════════ */
|
||
|
||
/**
|
||
* Draw N squares side by side onto an off-screen canvas.
|
||
* Each square is split vertically into colour slices (one per function).
|
||
* @param {string[]} colors CSS colour strings
|
||
* @param {number} nSquares 1, 2 or 3
|
||
* @returns {HTMLCanvasElement}
|
||
*/
|
||
function generateSquaresCanvas(colors, nSquares) {
|
||
const cssSquare = 22; // each square: 22×22 CSS px (canvas = 44×44 @ 2× DPR)
|
||
const cssGap = 1; // gap between squares (CSS px)
|
||
const cssW = nSquares * cssSquare + (nSquares - 1) * cssGap;
|
||
const cssH = cssSquare;
|
||
const DPR = 2;
|
||
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = cssW * DPR;
|
||
canvas.height = cssH * DPR;
|
||
const ctx = canvas.getContext('2d');
|
||
ctx.scale(DPR, DPR);
|
||
|
||
for (let s = 0; s < nSquares; s++) {
|
||
const x = s * (cssSquare + cssGap);
|
||
|
||
// Fill – split vertically for multiple functions
|
||
if (colors.length === 1) {
|
||
ctx.fillStyle = colors[0];
|
||
ctx.fillRect(x, 0, cssSquare, cssSquare);
|
||
} else {
|
||
const sliceW = cssSquare / colors.length;
|
||
colors.forEach((color, i) => {
|
||
ctx.fillStyle = color;
|
||
ctx.fillRect(x + i * sliceW, 0, sliceW, cssSquare);
|
||
});
|
||
}
|
||
|
||
// White border
|
||
ctx.strokeStyle = '#ffffff';
|
||
ctx.lineWidth = 1.5;
|
||
ctx.strokeRect(x + 0.75, 0.75, cssSquare - 1.5, cssSquare - 1.5);
|
||
}
|
||
|
||
return canvas;
|
||
}
|
||
|
||
/**
|
||
* Draw an orange rectangle outline sized to enclose N side-by-side squares.
|
||
* Used for the selection highlight symbol layer.
|
||
* @param {number} nSquares 1, 2 or 3
|
||
* @returns {HTMLCanvasElement}
|
||
*/
|
||
function generateSelectionCanvas(nSquares) {
|
||
const cssSquare = 22;
|
||
const cssGap = 1;
|
||
const pad = 4; // extra space outside the squares (CSS px)
|
||
const cssW = nSquares * cssSquare + (nSquares - 1) * cssGap + pad * 2;
|
||
const cssH = cssSquare + pad * 2;
|
||
const DPR = 2;
|
||
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = cssW * DPR;
|
||
canvas.height = cssH * DPR;
|
||
const ctx = canvas.getContext('2d');
|
||
ctx.scale(DPR, DPR);
|
||
|
||
ctx.strokeStyle = '#ff9500';
|
||
ctx.lineWidth = 3;
|
||
ctx.strokeRect(1, 1, cssW - 2, cssH - 2);
|
||
|
||
return canvas;
|
||
}
|
||
|
||
/**
|
||
* Register MapLibre sprite images for every unique `squares_key` in features.
|
||
* Key format: "cbsq:<fn1|fn2>:<tier>" where tier = 1 | 2 | 3.
|
||
* @param {Object[]} features GeoJSON feature array (already enriched with squares_key)
|
||
*/
|
||
function buildSquaresImages(features) {
|
||
for (const f of features) {
|
||
const key = f.properties?.squares_key;
|
||
if (!key || cbMap.hasImage(key)) continue;
|
||
|
||
const lastColon = key.lastIndexOf(':');
|
||
const tier = parseInt(key.slice(lastColon + 1), 10) || 1;
|
||
const fnsRaw = key.slice('cbsq:'.length, lastColon);
|
||
const fnList = fnsRaw ? fnsRaw.split('|') : ['autre'];
|
||
const colors = fnList.map(fn => window.CB_FUNCTION_COLORS[fn] || '#95a5a6');
|
||
|
||
const canvas = generateSquaresCanvas(colors, tier);
|
||
const imageData = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height);
|
||
cbMap.addImage(key, imageData, { pixelRatio: 2 });
|
||
}
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════════════
|
||
Colour expression helpers
|
||
══════════════════════════════════════════════════════════════════════ */
|
||
|
||
/**
|
||
* Build a MapLibre `match` expression that maps a feature property to a
|
||
* colour, falling back to grey.
|
||
* @param {string} mode 'function' or 'type'
|
||
* @returns {Array} MapLibre expression
|
||
*/
|
||
function buildColorExpression() {
|
||
const expr = ['match', ['get', 'function']];
|
||
for (const [key, color] of Object.entries(window.CB_FUNCTION_COLORS)) {
|
||
expr.push(key, color);
|
||
}
|
||
expr.push('#95a5a6'); // fallback
|
||
return expr;
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════════════
|
||
Map load → add sources & layers
|
||
══════════════════════════════════════════════════════════════════════ */
|
||
|
||
cbMap.on('load', function () {
|
||
cbMap.addControl(new maplibregl.NavigationControl(), 'top-right');
|
||
cbMap.addControl(new maplibregl.ScaleControl(), 'bottom-right');
|
||
|
||
// Empty GeoJSON source – filled by loadData()
|
||
cbMap.addSource('concrete-blocks', {
|
||
type: 'geojson',
|
||
data: { type: 'FeatureCollection', features: [] },
|
||
});
|
||
|
||
const colorExpr = buildColorExpression();
|
||
|
||
// ── Layer 1 : filled inner circle (used in "by block type" mode) ───
|
||
// Starts hidden because the default mode is "by function" (symbol layer).
|
||
cbMap.addLayer({
|
||
id: 'cb-inner',
|
||
type: 'circle',
|
||
source: 'concrete-blocks',
|
||
layout: { 'visibility': 'none' },
|
||
paint: {
|
||
'circle-radius': ['interpolate', ['linear'], ['zoom'], 10, 4, 14, 7, 17, 10],
|
||
'circle-color': colorExpr,
|
||
'circle-opacity': 0.9,
|
||
'circle-stroke-width': 1,
|
||
'circle-stroke-color': '#ffffff',
|
||
},
|
||
});
|
||
|
||
// ── Selected-point highlight (rectangle outline behind the square icons) ──
|
||
// Pre-register one outline icon per tier (1/2/3 squares wide)
|
||
[1, 2, 3].forEach(tier => {
|
||
const canvas = generateSelectionCanvas(tier);
|
||
const imageData = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height);
|
||
cbMap.addImage(`cbsel:${tier}`, imageData, { pixelRatio: 2 });
|
||
});
|
||
|
||
cbMap.addSource('selected-block', {
|
||
type: 'geojson',
|
||
data: { type: 'FeatureCollection', features: [] },
|
||
});
|
||
cbMap.addLayer({
|
||
id: 'cb-selected',
|
||
type: 'symbol',
|
||
source: 'selected-block',
|
||
layout: {
|
||
'icon-image': ['get', 'selection_key'],
|
||
'icon-size': ['interpolate', ['linear'], ['zoom'], 10, 0.3, 14, 0.5, 17, 0.75],
|
||
'icon-allow-overlap': true,
|
||
'icon-ignore-placement': true,
|
||
},
|
||
});
|
||
|
||
// ── Square symbols (1 / 2 / 3 squares side by side) ───────────────
|
||
cbMap.addLayer({
|
||
id: 'cb-inner-symbols',
|
||
type: 'symbol',
|
||
source: 'concrete-blocks',
|
||
layout: {
|
||
'icon-image': ['get', 'squares_key'],
|
||
// At icon-size 0.5 (zoom 14) each square renders as 14×14 CSS px.
|
||
'icon-size': ['interpolate', ['linear'], ['zoom'], 10, 0.3, 14, 0.5, 17, 0.75],
|
||
'icon-allow-overlap': true,
|
||
'icon-ignore-placement': true,
|
||
},
|
||
});
|
||
|
||
// ── Projects New-Jersey layer ─────────────────────────────────────
|
||
const njColorExpr = buildNjColorExpr();
|
||
cbMap.addSource('nj-projects', {
|
||
type: 'geojson',
|
||
data: window.CB_PROJECTS_NJ_URL,
|
||
});
|
||
cbMap.addLayer(
|
||
{
|
||
id: 'nj-projects-fill',
|
||
type: 'fill',
|
||
source: 'nj-projects',
|
||
paint: {
|
||
'fill-color': njColorExpr,
|
||
'fill-opacity': 0.2,
|
||
},
|
||
},
|
||
'cb-inner'
|
||
);
|
||
cbMap.addLayer(
|
||
{
|
||
id: 'nj-projects-outline',
|
||
type: 'line',
|
||
source: 'nj-projects',
|
||
paint: {
|
||
'line-color': njColorExpr,
|
||
'line-width': 2,
|
||
'line-opacity': 0.9,
|
||
},
|
||
},
|
||
'cb-inner'
|
||
);
|
||
|
||
// Pointer cursor on hover
|
||
cbMap.on('mouseenter', 'nj-projects-fill', () => { cbMap.getCanvas().style.cursor = 'pointer'; });
|
||
cbMap.on('mouseleave', 'nj-projects-fill', () => { cbMap.getCanvas().style.cursor = ''; });
|
||
|
||
// Click → show project info in a popup
|
||
cbMap.on('click', 'nj-projects-fill', (e) => {
|
||
if (!e.features || !e.features.length) return;
|
||
// If an asset is at this point, ignore the project click
|
||
const assetFeatures = cbMap.queryRenderedFeatures(e.point, { layers: ['cb-inner', 'cb-inner-symbols'] });
|
||
if (assetFeatures.length > 0) return;
|
||
e.originalEvent._cbFeatureClicked = true;
|
||
const p = e.features[0].properties;
|
||
new maplibregl.Popup()
|
||
.setLngLat(e.lngLat)
|
||
.setHTML(`
|
||
<strong>${p.code}</strong><br>
|
||
${p.name}<br>
|
||
<span class="text-muted small">${p.status || ''}</span>
|
||
`)
|
||
.addTo(cbMap);
|
||
});
|
||
|
||
// ── Regional roads layer ─────────────────────────────────────────
|
||
cbMap.addSource('regional-roads', {
|
||
type: 'geojson',
|
||
data: window.CB_REGIONAL_ROADS_URL,
|
||
});
|
||
cbMap.addLayer(
|
||
{
|
||
id: 'regional-roads',
|
||
type: 'line',
|
||
source: 'regional-roads',
|
||
paint: {
|
||
'line-color': '#FF5733',
|
||
'line-width': [
|
||
'interpolate', ['linear'], ['zoom'],
|
||
10, 0.1,
|
||
13, 0.5,
|
||
16, 1,
|
||
19, 1.5,
|
||
],
|
||
'line-opacity': 0.8,
|
||
},
|
||
},
|
||
'cb-inner' // insert below the point layers
|
||
);
|
||
|
||
// ── Generate missing square images on-the-fly ──────────────────────
|
||
cbMap.on('styleimagemissing', (e) => {
|
||
const key = e.id;
|
||
if (!key.startsWith('cbsq:')) return;
|
||
const lastColon = key.lastIndexOf(':');
|
||
const tier = parseInt(key.slice(lastColon + 1), 10) || 1;
|
||
const fnsRaw = key.slice('cbsq:'.length, lastColon);
|
||
const fnList = fnsRaw ? fnsRaw.split('|') : ['autre'];
|
||
const colors = fnList.map(fn => window.CB_FUNCTION_COLORS[fn] || '#95a5a6');
|
||
const canvas = generateSquaresCanvas(colors, tier);
|
||
const imageData = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height);
|
||
if (cbMap.hasImage(key)) cbMap.removeImage(key);
|
||
cbMap.addImage(key, imageData, { pixelRatio: 2 });
|
||
});
|
||
|
||
// ── Pointer cursor on hover ───────────────────────────────────────
|
||
['cb-inner', 'cb-inner-symbols'].forEach(layerId => {
|
||
cbMap.on('mouseenter', layerId, () => { cbMap.getCanvas().style.cursor = 'pointer'; });
|
||
cbMap.on('mouseleave', layerId, () => { cbMap.getCanvas().style.cursor = ''; });
|
||
});
|
||
|
||
// ── Click → detail panel + selection highlight ─────────────────────
|
||
['cb-inner', 'cb-inner-symbols'].forEach(layerId => {
|
||
cbMap.on('click', layerId, (e) => {
|
||
if (!e.features || !e.features.length) return;
|
||
e.originalEvent._cbFeatureClicked = true; // prevent map-level deselect
|
||
|
||
// In edit mode (tab active or multi-select on) don't open the detail panel
|
||
const editPane = document.getElementById('edit-panel');
|
||
if (_selectMode || (editPane && editPane.classList.contains('show'))) return;
|
||
|
||
const feat = e.features[0];
|
||
const coords = feat.geometry.coordinates;
|
||
setSelected(feat);
|
||
showDetail(feat.properties, coords[1], coords[0]);
|
||
});
|
||
});
|
||
|
||
// ── Click on empty area → deselect ──────────────────────────────
|
||
cbMap.on('click', (e) => {
|
||
if (e.originalEvent._cbFeatureClicked) return;
|
||
setSelected(null);
|
||
});
|
||
|
||
loadData();
|
||
});
|
||
|
||
/* ══════════════════════════════════════════════════════════════════════
|
||
Data loading
|
||
══════════════════════════════════════════════════════════════════════ */
|
||
|
||
function getFilterParams() {
|
||
const fns = [...document.querySelectorAll('.filter-function:checked')].map(el => el.value);
|
||
const statuses = [...document.querySelectorAll('.filter-status:checked')].map(el => el.value);
|
||
const actions = [...document.querySelectorAll('.filter-action:checked')].map(el => el.value);
|
||
const minBlocks = document.getElementById('filter-min-blocks')?.value.trim() || '';
|
||
const maxBlocks = document.getElementById('filter-max-blocks')?.value.trim() || '';
|
||
const minPriority = document.getElementById('filter-min-priority')?.value.trim() || '';
|
||
const maxPriority = document.getElementById('filter-max-priority')?.value.trim() || '';
|
||
const params = new URLSearchParams();
|
||
fns.forEach(v => params.append('function', v));
|
||
statuses.forEach(v => params.append('status', v));
|
||
actions.forEach(v => params.append('action', v));
|
||
if (minBlocks) params.set('min_blocks', minBlocks);
|
||
if (maxBlocks) params.set('max_blocks', maxBlocks);
|
||
if (minPriority) params.set('min_priority', minPriority);
|
||
if (maxPriority) params.set('max_priority', maxPriority);
|
||
return params;
|
||
}
|
||
|
||
async function loadData() {
|
||
const params = getFilterParams();
|
||
const url = `${window.CB_GEOJSON_URL}?${params.toString()}`;
|
||
try {
|
||
const resp = await fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
|
||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||
const geojson = await resp.json();
|
||
const features = geojson.features || [];
|
||
// Enrich features with squares_key (tier based on block_count)
|
||
for (const f of features) {
|
||
const bc = f.properties.block_count;
|
||
const tier = (bc > 10) ? 3 : (bc > 3 ? 2 : 1);
|
||
const fnsRaw = (f.properties.functions_key || 'cb:autre').replace(/^cb:/, '');
|
||
f.properties.squares_key = `cbsq:${fnsRaw}:${tier}`;
|
||
}
|
||
currentFeatures = features;
|
||
buildSquaresImages(features); // pre-build square icons for all unique combinations
|
||
cbMap.getSource('concrete-blocks').setData(geojson);
|
||
updateStats(features.length, geojson.total_blocks || 0);
|
||
} catch (err) {
|
||
console.error('Erreur de chargement des blocs béton :', err);
|
||
}
|
||
}
|
||
|
||
function updateStats(count, totalBlocks) {
|
||
const el = document.getElementById('cb-stats');
|
||
if (el) {
|
||
el.textContent = _ip(
|
||
_ngt('%(count)s asset affiché', '%(count)s assets affichés', count),
|
||
{ count }, true
|
||
);
|
||
}
|
||
const elBlocks = document.getElementById('cb-stats-blocks');
|
||
if (elBlocks) {
|
||
const tb = totalBlocks || 0;
|
||
elBlocks.textContent = _ip(
|
||
_ngt('%(count)s bloc au total', '%(count)s blocs au total', tb),
|
||
{ count: tb }, true
|
||
);
|
||
}
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════════════
|
||
NJ Projects list (card panel)
|
||
══════════════════════════════════════════════════════════════════════ */
|
||
|
||
function renderNjStatusFilters() {
|
||
const container = document.getElementById('nj-status-filters');
|
||
if (!container) return;
|
||
container.innerHTML = Object.entries(NJ_PROJECT_STATUS_CONFIG).map(([key, cfg]) => `
|
||
<div class="form-check">
|
||
<input class="form-check-input nj-filter-status" type="checkbox"
|
||
id="nj-st-${key}" value="${key}" checked>
|
||
<label class="form-check-label" for="nj-st-${key}">
|
||
<span style="display:inline-block;width:10px;height:10px;border-radius:2px;
|
||
background:${cfg.color};margin-right:5px;vertical-align:middle;"></span>
|
||
<span class="small">${cfg.label}</span>
|
||
</label>
|
||
</div>`
|
||
).join('');
|
||
container.querySelectorAll('.nj-filter-status').forEach(cb => {
|
||
cb.addEventListener('change', renderProjectCards);
|
||
});
|
||
}
|
||
|
||
async function loadProjectsList() {
|
||
if (_njProjects !== null) { renderProjectCards(); return; }
|
||
const container = document.getElementById('nj-projects-list');
|
||
if (container) {
|
||
container.innerHTML = `<div class="text-center text-muted py-4">
|
||
<span class="spinner-border spinner-border-sm" role="status"></span>
|
||
</div>`;
|
||
}
|
||
try {
|
||
const resp = await fetch(window.CB_PROJECTS_NJ_LIST_URL, { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
|
||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||
const data = await resp.json();
|
||
_njProjects = data.projects || [];
|
||
} catch (err) {
|
||
console.error('Erreur chargement projets NJ :', err);
|
||
_njProjects = [];
|
||
if (container) container.innerHTML = `<p class="text-danger small p-2">${_gt('Erreur de chargement')}</p>`;
|
||
return;
|
||
}
|
||
renderProjectCards();
|
||
}
|
||
|
||
function renderProjectCards() {
|
||
const container = document.getElementById('nj-projects-list');
|
||
if (!container || _njProjects === null) return;
|
||
|
||
const search = (document.getElementById('nj-search')?.value || '').toLowerCase().trim();
|
||
const statuses = [...document.querySelectorAll('.nj-filter-status:checked')].map(el => el.value);
|
||
const dateFrom = document.getElementById('nj-date-from')?.value || '';
|
||
const dateTo = document.getElementById('nj-date-to')?.value || '';
|
||
|
||
let filtered = _njProjects;
|
||
|
||
if (search) {
|
||
filtered = filtered.filter(p =>
|
||
(p.code || '').toLowerCase().includes(search) ||
|
||
String(p.pmo_number ?? '').includes(search) ||
|
||
String(p.osiris_number ?? '').includes(search) ||
|
||
(p.name || '').toLowerCase().includes(search) ||
|
||
(p.description || '').toLowerCase().includes(search)
|
||
);
|
||
}
|
||
|
||
if (statuses.length < Object.keys(NJ_PROJECT_STATUS_CONFIG).length) {
|
||
filtered = filtered.filter(p => statuses.includes(p.status_key));
|
||
}
|
||
|
||
if (dateFrom) {
|
||
filtered = filtered.filter(p => !p.end_date_iso || p.end_date_iso >= dateFrom);
|
||
}
|
||
if (dateTo) {
|
||
filtered = filtered.filter(p => !p.start_date_iso || p.start_date_iso <= dateTo);
|
||
}
|
||
|
||
const countEl = document.getElementById('nj-projects-count');
|
||
if (countEl) {
|
||
countEl.textContent = _ip(
|
||
_ngt('%(count)s projet', '%(count)s projets', filtered.length),
|
||
{ count: filtered.length }, true
|
||
);
|
||
}
|
||
|
||
if (!filtered.length) {
|
||
container.innerHTML = `<p class="text-muted small text-center py-3">${_gt('Aucun projet trouvé.')}</p>`;
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = filtered.map(p => {
|
||
const cfg = NJ_PROJECT_STATUS_CONFIG[p.status_key] || { color: NJ_DEFAULT_COLOR, dark: false };
|
||
const textColor = cfg.dark ? '#212529' : '#ffffff';
|
||
const nums = [
|
||
p.pmo_number != null ? `PMO\u00a0${p.pmo_number}` : null,
|
||
p.osiris_number != null ? `OSIRIS\u00a0${p.osiris_number}` : null,
|
||
].filter(Boolean).join(' · ');
|
||
const dateRange = (p.start_date || p.end_date)
|
||
? `<div class="small text-muted mt-1"><i class="bi bi-calendar3 me-1"></i>${p.start_date || '—'} → ${p.end_date || '—'}</div>`
|
||
: '';
|
||
const centerBtn = p.has_geom
|
||
? `<button type="button" class="btn btn-outline-primary btn-sm py-0 px-2"
|
||
onclick="centerOnNjProject(${JSON.stringify(p.bbox)})"
|
||
title="${_gt('Centrer sur la carte')}">
|
||
<i class="bi bi-geo-alt"></i>
|
||
</button>`
|
||
: '';
|
||
return `
|
||
<div class="card mb-2 shadow-sm">
|
||
<div class="card-body p-2">
|
||
<div class="d-flex justify-content-between align-items-start gap-1 mb-1">
|
||
<span class="fw-bold small text-nowrap">${p.code}</span>
|
||
<span class="badge rounded-pill small"
|
||
style="background:${cfg.color};color:${textColor};white-space:nowrap;">${p.status}</span>
|
||
</div>
|
||
<div class="small fw-semibold mb-1 text-truncate" title="${p.name}">${p.name}</div>
|
||
${p.description ? `<div class="small text-muted mb-1 text-truncate" title="${p.description}">${p.description}</div>` : ''}
|
||
${nums ? `<div class="small text-muted">${nums}</div>` : ''}
|
||
${dateRange}
|
||
<div class="d-flex gap-2 mt-2">
|
||
<a href="${p.url}" target="_blank"
|
||
class="btn btn-outline-secondary btn-sm py-0 px-2 flex-grow-1">
|
||
<i class="bi bi-box-arrow-up-right me-1"></i>${_gt('Ouvrir')}
|
||
</a>
|
||
${centerBtn}
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function centerOnNjProject(bbox) {
|
||
if (!bbox || bbox.length < 4) return;
|
||
cbMap.fitBounds([[bbox[0], bbox[1]], [bbox[2], bbox[3]]], { padding: 80, maxZoom: 16 });
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════════════
|
||
Legend rendering
|
||
══════════════════════════════════════════════════════════════════════ */
|
||
|
||
function renderLegend() {
|
||
const container = document.getElementById('legend-items');
|
||
if (!container) return;
|
||
|
||
const entries = Object.entries(window.FUNCTION_LABELS);
|
||
const colors = window.CB_FUNCTION_COLORS;
|
||
|
||
container.innerHTML = entries.map(([key, label]) => {
|
||
const color = colors[key] || '#95a5a6';
|
||
return `
|
||
<div class="legend-item">
|
||
<span style="display:inline-block;width:12px;height:12px;border-radius:1px;
|
||
background:${color};flex-shrink:0;margin-right:10px;
|
||
border:1px solid rgba(255,255,255,0.6);"></span>
|
||
<span class="small">${label}</span>
|
||
</div>`;
|
||
}).join('');
|
||
|
||
// NJ projects status legend
|
||
const njContainer = document.getElementById('nj-legend-items');
|
||
if (njContainer) {
|
||
njContainer.innerHTML = Object.entries(NJ_PROJECT_STATUS_CONFIG).map(([, cfg]) => `
|
||
<div class="legend-item">
|
||
<span style="display:inline-block;width:18px;height:10px;border-radius:2px;
|
||
background:${cfg.color};flex-shrink:0;margin-right:8px;
|
||
border:1px solid rgba(0,0,0,0.15);opacity:0.85;"></span>
|
||
<span class="small">${cfg.label}</span>
|
||
</div>`).join('');
|
||
}
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════════════
|
||
Selection management
|
||
══════════════════════════════════════════════════════════════════════ */
|
||
|
||
/**
|
||
* Highlight a feature on the map (orange ring around the selected point).
|
||
* Pass null to clear the selection.
|
||
* @param {Object|null} feature MapLibre feature (with .geometry)
|
||
*/
|
||
function setSelected(feature) {
|
||
_selectedFeature = feature || null;
|
||
const src = cbMap.getSource('selected-block');
|
||
if (!src) return;
|
||
|
||
if (!feature) {
|
||
src.setData({ type: 'FeatureCollection', features: [] });
|
||
// Clear the detail panel
|
||
const el = document.getElementById('cb-detail-content');
|
||
if (el) el.innerHTML = `<p class="text-muted small p-2 m-0">${_gt('Cliquez sur un point pour voir le détail.')}</p>`;
|
||
return;
|
||
}
|
||
|
||
const squaresKey = feature.properties?.squares_key || '';
|
||
const lastColon = squaresKey.lastIndexOf(':');
|
||
const tier = parseInt(squaresKey.slice(lastColon + 1), 10) || 1;
|
||
|
||
src.setData({
|
||
type: 'FeatureCollection',
|
||
features: [{ type: 'Feature', geometry: feature.geometry, properties: { selection_key: `cbsel:${tier}` } }],
|
||
});
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════════════
|
||
Detail panel
|
||
══════════════════════════════════════════════════════════════════════ */
|
||
|
||
function showDetail(props, lat, lng) {
|
||
// Remember this selection so the edit tab can pre-load it
|
||
_lastDetailId = props.asset_id ?? null;
|
||
_lastDetailFeat = currentFeatures.find(f => f.properties.asset_id === _lastDetailId) || null;
|
||
|
||
// Switch to the detail tab
|
||
const detailTabEl = document.getElementById('detail-tab');
|
||
if (detailTabEl && window.bootstrap) {
|
||
bootstrap.Tab.getOrCreateInstance(detailTabEl).show();
|
||
}
|
||
|
||
const count = (props.block_count !== null && props.block_count !== undefined)
|
||
? Number(props.block_count)
|
||
: null;
|
||
|
||
// Square indicators – 1, 2 or 3 squares side by side
|
||
const squareStyle = 'display:inline-block;width:9px;height:9px;background:#546e7a;border:1px solid #fff;border-radius:1px;';
|
||
let squaresHtml = '';
|
||
if (count !== null) {
|
||
const tier = (count > 10) ? 3 : (count > 3 ? 2 : 1);
|
||
squaresHtml = `<span style="display:inline-flex;gap:1px;vertical-align:middle;">${
|
||
Array.from({ length: tier }, () => `<span style="${squareStyle}"></span>`).join('')
|
||
}</span>`;
|
||
}
|
||
|
||
document.getElementById('cb-detail-content').innerHTML = `
|
||
<div class="detail-section">
|
||
<div class="detail-label">${_gt('Adresse')}</div>
|
||
<div class="detail-value">${props.address || '—'}</div>
|
||
</div>
|
||
<div class="detail-section">
|
||
<div class="detail-label">${_gt('Nombre de blocs')}</div>
|
||
<div class="detail-value d-flex align-items-center gap-3">
|
||
<span>${count !== null ? count : '—'}</span>
|
||
${squaresHtml}
|
||
</div>
|
||
</div>
|
||
<div class="detail-section">
|
||
<div class="detail-label">${_gt('Fonction(s)')}</div>
|
||
<div class="detail-value">${props.functions_label || '—'}</div>
|
||
</div>
|
||
<div class="detail-section">
|
||
<div class="detail-label">${_gt('Statut')}</div>
|
||
<div class="detail-value">${props.status || '—'}</div>
|
||
</div>
|
||
<div class="detail-section">
|
||
<div class="detail-label">${_gt('Niveau de priorité')}</div>
|
||
<div class="detail-value">${props.priority_level !== null && props.priority_level !== undefined ? props.priority_level : '—'}</div>
|
||
</div>
|
||
<div class="detail-section">
|
||
<div class="detail-label">${_gt('Action recommandée')}</div>
|
||
<div class="detail-value">${props.recommended_action || '—'}</div>
|
||
</div>
|
||
<div class="detail-section">
|
||
<div class="detail-label">${_gt("Date d'action cible")}</div>
|
||
<div class="detail-value">${props.targeted_action_date || '—'}</div>
|
||
</div>
|
||
<div class="detail-section">
|
||
<div class="detail-label">${_gt("Date d'enlèvement")}</div>
|
||
<div class="detail-value">${props.uninstallation_date || '—'}</div>
|
||
</div>
|
||
<div class="detail-section">
|
||
<div class="detail-label">${_gt('Photos')}</div>
|
||
<div class="detail-value" id="cb-detail-photos">
|
||
<span class="text-muted small">
|
||
<span class="spinner-border spinner-border-sm me-1" role="status"></span>${_gt('Chargement...')}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
${lat != null && lng != null ? `
|
||
<div class="detail-section pt-2">
|
||
<div class="d-flex gap-2 flex-wrap">
|
||
<a href="https://www.google.com/maps/@${lat},${lng},18z?layer=c&cbll=${lat},${lng}&cbp=0,0,0,0,0"
|
||
target="_blank"
|
||
class="btn btn-outline-primary btn-sm"
|
||
title="${_gt('Voir sur Street View')}">
|
||
<i class="bi bi-person-walking"></i> Street View
|
||
</a>
|
||
<a href="https://www.mapillary.com/app/?lat=${lat}&lng=${lng}&z=17"
|
||
target="_blank"
|
||
class="btn btn-outline-secondary btn-sm"
|
||
title="${_gt('Voir sur Mapillary')}">
|
||
<i class="bi bi-images"></i> Mapillary
|
||
</a>
|
||
</div>
|
||
</div>` : ''}
|
||
`;
|
||
|
||
_loadAssetPhotos(props.asset_id);
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════════════
|
||
Photo thumbnails & carousel
|
||
══════════════════════════════════════════════════════════════════════ */
|
||
|
||
async function _loadAssetPhotos(assetId) {
|
||
const container = document.getElementById('cb-detail-photos');
|
||
if (!container) return;
|
||
|
||
if (!assetId) {
|
||
container.innerHTML = `<span class="text-muted small">${_gt('Aucune photo')}</span>`;
|
||
return;
|
||
}
|
||
|
||
const url = window.CB_ASSET_PHOTOS_BASE_URL.replace(/\/0\/([?#].*)?$/, `/${assetId}/`);
|
||
try {
|
||
const resp = await fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
|
||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||
const data = await resp.json();
|
||
const photos = data.photos || [];
|
||
|
||
if (!photos.length) {
|
||
container.innerHTML = `<span class="text-muted small">${_gt('Aucune photo')}</span>`;
|
||
return;
|
||
}
|
||
|
||
window._cbCurrentPhotos = photos;
|
||
|
||
container.innerHTML = `<div class="d-flex flex-wrap gap-1 mt-1">${
|
||
photos.map((p, i) =>
|
||
`<img src="${p.thumb_url}" alt=""
|
||
style="width:72px;height:72px;object-fit:cover;border-radius:4px;cursor:pointer;"
|
||
onclick="openCbPhotoCarousel(${i})"
|
||
onerror="this.style.display='none'">`
|
||
).join('')
|
||
}</div>`;
|
||
} catch (err) {
|
||
container.innerHTML = `<span class="text-muted small">${_gt('Erreur de chargement')}</span>`;
|
||
}
|
||
}
|
||
|
||
function openCbPhotoCarousel(startIndex) {
|
||
const photos = window._cbCurrentPhotos || [];
|
||
if (!photos.length) return;
|
||
|
||
const inner = document.getElementById('cbPhotoCarouselInner');
|
||
if (!inner) return;
|
||
|
||
inner.innerHTML = photos.map((p, i) =>
|
||
`<div class="carousel-item ${i === startIndex ? 'active' : ''}">
|
||
<div class="d-flex justify-content-center align-items-center bg-black" style="min-height:50vh;">
|
||
<img src="${p.full_url}" alt="Photo ${i + 1}"
|
||
style="max-height:80vh;max-width:100%;object-fit:contain;">
|
||
</div>
|
||
</div>`
|
||
).join('');
|
||
|
||
bootstrap.Modal.getOrCreateInstance(document.getElementById('cbPhotoModal')).show();
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════════════
|
||
CSV export
|
||
══════════════════════════════════════════════════════════════════════ */
|
||
|
||
function exportCsv() {
|
||
if (!currentFeatures || !currentFeatures.length) return;
|
||
|
||
const columns = [
|
||
'asset_id', 'address',
|
||
'block_count', 'functions', 'functions_label',
|
||
'status', 'targeted_action_date', 'longitude', 'latitude', 'geometry',
|
||
'updated_at'
|
||
];
|
||
|
||
/** Wrap a value in double-quotes if it contains special CSV characters. */
|
||
const csvEscape = val => {
|
||
if (val === null || val === undefined) return '';
|
||
const s = String(val);
|
||
return (s.includes(',') || s.includes('"') || s.includes('\n') || s.includes('\r'))
|
||
? '"' + s.replace(/"/g, '""') + '"'
|
||
: s;
|
||
};
|
||
|
||
const rows = [columns.join(',')];
|
||
|
||
for (const f of currentFeatures) {
|
||
const p = f.properties || {};
|
||
const coords = f.geometry && f.geometry.coordinates;
|
||
const lng = coords ? coords[0] : '';
|
||
const lat = coords ? coords[1] : '';
|
||
|
||
// `functions` is a real JS array in currentFeatures (raw GeoJSON)
|
||
const fnsArray = Array.isArray(p.functions) ? p.functions : [];
|
||
const fnsStr = fnsArray.join('|');
|
||
|
||
rows.push([
|
||
csvEscape(p.asset_id),
|
||
csvEscape(p.address),
|
||
csvEscape(p.block_count),
|
||
csvEscape(fnsStr),
|
||
csvEscape(p.functions_label),
|
||
csvEscape(p.status),
|
||
csvEscape(p.targeted_action_date),
|
||
csvEscape(lat),
|
||
csvEscape(lng),
|
||
csvEscape(JSON.stringify(f.geometry)),
|
||
csvEscape(p.updated_at),
|
||
].join(','));
|
||
}
|
||
|
||
// Prepend UTF-8 BOM so Excel opens the file with correct encoding
|
||
const csvContent = '\uFEFF' + rows.join('\r\n');
|
||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = 'blocs_beton.csv';
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════════════
|
||
UI event listeners (run after DOM ready)
|
||
══════════════════════════════════════════════════════════════════════ */
|
||
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
// Initial legend render
|
||
renderLegend();
|
||
|
||
// Populate NJ status filter checkboxes (projects tab)
|
||
renderNjStatusFilters();
|
||
|
||
// NJ Projects tab – lazy-load on first show
|
||
document.getElementById('projects-tab')?.addEventListener('shown.bs.tab', loadProjectsList);
|
||
document.getElementById('nj-search')?.addEventListener('input', renderProjectCards);
|
||
document.getElementById('nj-date-from')?.addEventListener('change', renderProjectCards);
|
||
document.getElementById('nj-date-to')?.addEventListener('change', renderProjectCards);
|
||
|
||
// Export CSV
|
||
document.getElementById('cb-export-csv')?.addEventListener('click', exportCsv);
|
||
|
||
// Export NJ projects to Excel
|
||
document.getElementById('nj-export-excel')?.addEventListener('click', () => {
|
||
const search = (document.getElementById('nj-search')?.value || '').trim();
|
||
const statuses = [...document.querySelectorAll('.nj-filter-status:checked')].map(el => el.value);
|
||
const dateFrom = document.getElementById('nj-date-from')?.value || '';
|
||
const dateTo = document.getElementById('nj-date-to')?.value || '';
|
||
|
||
const params = new URLSearchParams();
|
||
if (search) params.set('q', search);
|
||
statuses.forEach(st => params.append('status', st));
|
||
if (dateFrom) params.set('date_from', dateFrom);
|
||
if (dateTo) params.set('date_to', dateTo);
|
||
|
||
window.location.href = `${window.CB_PROJECTS_NJ_EXPORT_EXCEL_URL}?${params.toString()}`;
|
||
});
|
||
|
||
// Toggle NJ projects layer
|
||
document.getElementById('toggle-nj-projects')?.addEventListener('change', (e) => {
|
||
const vis = e.target.checked ? 'visible' : 'none';
|
||
['nj-projects-fill', 'nj-projects-outline'].forEach(id => {
|
||
if (cbMap.getLayer(id)) cbMap.setLayoutProperty(id, 'visibility', vis);
|
||
});
|
||
});
|
||
|
||
// Apply filters
|
||
document.getElementById('cb-apply-filters')?.addEventListener('click', loadData);
|
||
|
||
// Reset filters
|
||
document.getElementById('cb-reset-filters')?.addEventListener('click', () => {
|
||
document.querySelectorAll('.filter-function, .filter-status, .filter-action')
|
||
.forEach(cb => { cb.checked = false; });
|
||
['filter-min-blocks', 'filter-max-blocks',
|
||
'filter-min-priority', 'filter-max-priority'].forEach(id => {
|
||
const el = document.getElementById(id);
|
||
if (el) el.value = '';
|
||
});
|
||
loadData();
|
||
});
|
||
|
||
// ── Edit UI
|
||
initEditUI();
|
||
});
|
||
|
||
/* ══════════════════════════════════════════════════════════════════════
|
||
Edit / Create / Multi-select
|
||
══════════════════════════════════════════════════════════════════════ */
|
||
|
||
let _selectMode = false; // multi-select mode active
|
||
let _pickPositionMode = null; // null | 'edit' | 'create' (clicking map sets position)
|
||
let _bulkSelectedIds = new Set(); // IDs of assets in bulk selection
|
||
let _bulkSelectedFeats = new Map(); // id → GeoJSON feature
|
||
let _editDragMarker = null; // maplibregl.Marker for position editing
|
||
let _lastDetailId = null; // asset_id last shown in the detail panel
|
||
let _lastDetailFeat = null; // GeoJSON feature last shown in the detail panel
|
||
|
||
/** Helper: build URL by replacing /0/ with /id/ */
|
||
function _assetUrl(base, id) {
|
||
// base contains a placeholder /0/ e.g. /maps/concrete_blocks/api/asset/0/update/
|
||
return base.replace('/0/', `/${id}/`);
|
||
}
|
||
|
||
/** Helper: CSRF-aware POST */
|
||
async function _csrfPost(url, body) {
|
||
return fetch(url, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-CSRFToken': window.CB_CSRF_TOKEN || '',
|
||
'X-Requested-With': 'XMLHttpRequest',
|
||
},
|
||
body: JSON.stringify(body),
|
||
});
|
||
}
|
||
|
||
/** Show a feedback message in a container element. */
|
||
function _showFeedback(elId, message, isError = false) {
|
||
const el = document.getElementById(elId);
|
||
if (!el) return;
|
||
el.style.display = '';
|
||
el.className = `alert alert-${isError ? 'danger' : 'success'} py-2 px-3 small mt-2`;
|
||
el.textContent = message;
|
||
if (!isError) {
|
||
setTimeout(() => { el.style.display = 'none'; }, 3000);
|
||
}
|
||
}
|
||
|
||
/* ── Multi-select layer ─────────────────────────────────────────────── */
|
||
|
||
/** Register the selected-multi source/layer once the map is loaded. */
|
||
function _initMultiSelectLayer() {
|
||
if (cbMap.getSource('selected-multi')) return;
|
||
cbMap.addSource('selected-multi', {
|
||
type: 'geojson',
|
||
data: { type: 'FeatureCollection', features: [] },
|
||
});
|
||
|
||
// Register highlight icon (solid cyan square outline)
|
||
[1, 2, 3].forEach(tier => {
|
||
const key = `cbhighlight:${tier}`;
|
||
if (cbMap.hasImage(key)) return;
|
||
const cssSquare = 28; const cssGap = 1; const pad = 3; const DPR = 2;
|
||
const cssW = tier * cssSquare + (tier - 1) * cssGap + pad * 2;
|
||
const cssH = cssSquare + pad * 2;
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = cssW * DPR; canvas.height = cssH * DPR;
|
||
const ctx = canvas.getContext('2d');
|
||
ctx.scale(DPR, DPR);
|
||
ctx.strokeStyle = '#0d6efd'; ctx.lineWidth = 3;
|
||
ctx.strokeRect(1.5, 1.5, cssW - 3, cssH - 3);
|
||
cbMap.addImage(key, ctx.getImageData(0, 0, canvas.width, canvas.height), { pixelRatio: 2 });
|
||
});
|
||
|
||
cbMap.addLayer({
|
||
id: 'cb-selected-multi',
|
||
type: 'symbol',
|
||
source: 'selected-multi',
|
||
layout: {
|
||
'icon-image': ['get', 'highlight_key'],
|
||
'icon-size': ['interpolate', ['linear'], ['zoom'], 10, 0.3, 14, 0.5, 17, 0.75],
|
||
'icon-allow-overlap': true,
|
||
'icon-ignore-placement': true,
|
||
},
|
||
});
|
||
}
|
||
|
||
/** Refresh the multi-select highlight layer from _bulkSelectedFeats. */
|
||
function _refreshMultiSelectLayer() {
|
||
const src = cbMap.getSource('selected-multi');
|
||
if (!src) return;
|
||
const features = [];
|
||
for (const [, feat] of _bulkSelectedFeats) {
|
||
const squaresKey = feat.properties?.squares_key || 'cbsq:autre:1';
|
||
const tier = parseInt(squaresKey.slice(squaresKey.lastIndexOf(':') + 1), 10) || 1;
|
||
features.push({
|
||
type: 'Feature',
|
||
geometry: feat.geometry,
|
||
properties: { highlight_key: `cbhighlight:${tier}` },
|
||
});
|
||
}
|
||
src.setData({ type: 'FeatureCollection', features });
|
||
_refreshSelectionCounter();
|
||
}
|
||
|
||
function _refreshSelectionCounter() {
|
||
const count = _bulkSelectedIds.size;
|
||
const info = document.getElementById('cb-selection-info');
|
||
const cnt = document.getElementById('cb-selection-count');
|
||
if (info) info.style.display = count > 0 ? '' : 'none';
|
||
if (cnt) cnt.textContent = count;
|
||
|
||
// Show correct edit sub-panel
|
||
if (count >= 2) {
|
||
_showEditPanel('bulk');
|
||
} else if (count === 1) {
|
||
// Load single-edit form for the selected asset
|
||
const [id] = _bulkSelectedIds;
|
||
if (id) _openSingleEdit(id, _bulkSelectedFeats.get(id));
|
||
} else if (count === 0 && _selectMode) {
|
||
_showEditPanel('empty');
|
||
}
|
||
}
|
||
|
||
/** Toggle one asset in/out of the bulk selection. */
|
||
function _toggleBulkSelect(feature) {
|
||
const id = feature.properties?.asset_id;
|
||
if (!id) return;
|
||
if (_bulkSelectedIds.has(id)) {
|
||
_bulkSelectedIds.delete(id);
|
||
_bulkSelectedFeats.delete(id);
|
||
} else {
|
||
_bulkSelectedIds.add(id);
|
||
_bulkSelectedFeats.set(id, feature);
|
||
}
|
||
_refreshMultiSelectLayer();
|
||
}
|
||
|
||
/** Add all features whose bounding box overlaps [x0,y0,x1,y1] in pixel coords. */
|
||
function _selectFeaturesInBox(x0, y0, x1, y1) {
|
||
const bbox = [
|
||
[Math.min(x0, x1), Math.min(y0, y1)],
|
||
[Math.max(x0, x1), Math.max(y0, y1)],
|
||
];
|
||
const hits = cbMap.queryRenderedFeatures(bbox, { layers: ['cb-inner-symbols', 'cb-inner'] });
|
||
for (const feat of hits) {
|
||
const id = feat.properties?.asset_id;
|
||
if (id && !_bulkSelectedIds.has(id)) {
|
||
// Re-fetch the enriched feature from currentFeatures cache
|
||
const cached = currentFeatures.find(f => f.properties.asset_id === id);
|
||
_bulkSelectedIds.add(id);
|
||
_bulkSelectedFeats.set(id, cached || feat);
|
||
}
|
||
}
|
||
_refreshMultiSelectLayer();
|
||
}
|
||
|
||
/* ── Edit sub-panel visibility ──────────────────────────────────────── */
|
||
|
||
function _showEditPanel(which) {
|
||
// which: 'empty' | 'single' | 'bulk' | 'create'
|
||
['cb-edit-empty', 'cb-edit-single', 'cb-edit-bulk', 'cb-create-form'].forEach(id => {
|
||
const el = document.getElementById(id);
|
||
if (el) el.style.display = 'none';
|
||
});
|
||
const target = {
|
||
empty: 'cb-edit-empty',
|
||
single: 'cb-edit-single',
|
||
bulk: 'cb-edit-bulk',
|
||
create: 'cb-create-form',
|
||
}[which];
|
||
if (target) {
|
||
const el = document.getElementById(target);
|
||
if (el) el.style.display = '';
|
||
}
|
||
}
|
||
|
||
/* ── Draggable position marker ──────────────────────────────────────── */
|
||
|
||
function _removeDragMarker() {
|
||
if (_editDragMarker) { _editDragMarker.remove(); _editDragMarker = null; }
|
||
}
|
||
|
||
function _placeDragMarker(lon, lat, onDragEnd) {
|
||
_removeDragMarker();
|
||
if (lon == null || lat == null || isNaN(lon) || isNaN(lat)) return;
|
||
_editDragMarker = new maplibregl.Marker({ draggable: true, color: '#FF5733' })
|
||
.setLngLat([lon, lat])
|
||
.addTo(cbMap);
|
||
_editDragMarker.on('dragend', () => {
|
||
const pos = _editDragMarker.getLngLat();
|
||
onDragEnd(pos.lng, pos.lat);
|
||
});
|
||
}
|
||
|
||
/* ── Single asset edit ──────────────────────────────────────────────── */
|
||
|
||
async function _openSingleEdit(assetId, mapFeature) {
|
||
// If we already have all values from the map feature, pre-fill optimistically
|
||
if (mapFeature) {
|
||
const p = mapFeature.properties;
|
||
_fillSingleForm({
|
||
id: p.asset_id,
|
||
code: p.address ? '' : '', // code not in GeoJSON props
|
||
address: p.address || '',
|
||
status: p.status || '',
|
||
main_function: p.function || '',
|
||
secondary_functions: Array.isArray(p.functions) ? p.functions.slice(1) : [],
|
||
number_of_blocks: p.block_count,
|
||
length: '',
|
||
recommended_action: '', // not in GeoJSON, will be replaced after fetch
|
||
priority_level: p.priority_level,
|
||
targeted_action_date: '',
|
||
uninstallation_date: '',
|
||
lon: mapFeature.geometry?.coordinates?.[0],
|
||
lat: mapFeature.geometry?.coordinates?.[1],
|
||
can_edit: window.CB_CAN_EDIT,
|
||
can_delete: window.CB_CAN_DELETE,
|
||
});
|
||
}
|
||
_showEditPanel('single');
|
||
|
||
// Fetch full detail
|
||
try {
|
||
const url = _assetUrl(window.CB_ASSET_DETAIL_BASE_URL, assetId);
|
||
const resp = await fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
|
||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||
const data = await resp.json();
|
||
_fillSingleForm(data);
|
||
} catch (err) {
|
||
console.error('Erreur chargement asset:', err);
|
||
}
|
||
}
|
||
|
||
function _fillSingleForm(data) {
|
||
const setVal = (id, v) => { const el = document.getElementById(id); if (el) el.value = (v ?? ''); };
|
||
|
||
document.getElementById('cb-edit-asset-id').value = data.id || '';
|
||
document.getElementById('cb-edit-code').textContent = data.code ? `#${data.code}` : '';
|
||
setVal('cb-edit-address', data.address);
|
||
setVal('cb-edit-status', data.status);
|
||
setVal('cb-edit-main-function', data.main_function);
|
||
setVal('cb-edit-nb-blocks', data.number_of_blocks);
|
||
setVal('cb-edit-length', data.length);
|
||
setVal('cb-edit-action', data.recommended_action);
|
||
setVal('cb-edit-priority', data.priority_level);
|
||
setVal('cb-edit-target-date', data.targeted_action_date);
|
||
setVal('cb-edit-removal-date', data.uninstallation_date);
|
||
|
||
// Secondary functions
|
||
const secFns = Array.isArray(data.secondary_functions) ? data.secondary_functions : [];
|
||
document.querySelectorAll('.cb-sec-fn').forEach(cb => {
|
||
cb.checked = secFns.includes(cb.value);
|
||
});
|
||
|
||
// Position
|
||
const lon = data.lon;
|
||
const lat = data.lat;
|
||
setVal('cb-edit-lon', lon != null ? Number(lon).toFixed(6) : '');
|
||
setVal('cb-edit-lat', lat != null ? Number(lat).toFixed(6) : '');
|
||
|
||
// Observation source link (read-only)
|
||
const obsLink = document.getElementById('cb-edit-observation-link');
|
||
if (obsLink) {
|
||
const obsId = data.observation_source_id;
|
||
if (obsId && window.CB_OBSERVATION_BASE_URL) {
|
||
const obsUrl = window.CB_OBSERVATION_BASE_URL.replace('/0/', `/${obsId}/`);
|
||
obsLink.innerHTML = `<a href="${obsUrl}" target="_blank"><i class="bi bi-binoculars me-1"></i>${_gt("Voir l'observation")} #${obsId}</a>`;
|
||
} else {
|
||
obsLink.textContent = '\u2014';
|
||
}
|
||
}
|
||
|
||
// Draggable marker
|
||
_placeDragMarker(lon, lat, (newLon, newLat) => {
|
||
setVal('cb-edit-lon', Number(newLon).toFixed(6));
|
||
setVal('cb-edit-lat', Number(newLat).toFixed(6));
|
||
});
|
||
|
||
// Disable form if no edit permission
|
||
const form = document.getElementById('cb-form-single');
|
||
if (form) {
|
||
const canEdit = data.can_edit !== false;
|
||
form.querySelectorAll('input, select, textarea, button[type="submit"]').forEach(el => {
|
||
el.disabled = !canEdit;
|
||
});
|
||
}
|
||
}
|
||
|
||
async function _saveSingleAsset(e) {
|
||
e.preventDefault();
|
||
const assetId = document.getElementById('cb-edit-asset-id')?.value;
|
||
if (!assetId) return;
|
||
|
||
const secFns = [...document.querySelectorAll('.cb-sec-fn:checked')].map(cb => cb.value);
|
||
const payload = {
|
||
address: document.getElementById('cb-edit-address')?.value || null,
|
||
status: document.getElementById('cb-edit-status')?.value || null,
|
||
main_function: document.getElementById('cb-edit-main-function')?.value || null,
|
||
secondary_functions: secFns.length ? secFns : null,
|
||
number_of_blocks: document.getElementById('cb-edit-nb-blocks')?.value || null,
|
||
length: document.getElementById('cb-edit-length')?.value || null,
|
||
recommended_action: document.getElementById('cb-edit-action')?.value || null,
|
||
priority_level: document.getElementById('cb-edit-priority')?.value || null,
|
||
targeted_action_date: document.getElementById('cb-edit-target-date')?.value || null,
|
||
uninstallation_date: document.getElementById('cb-edit-removal-date')?.value || null,
|
||
lon: document.getElementById('cb-edit-lon')?.value || null,
|
||
lat: document.getElementById('cb-edit-lat')?.value || null,
|
||
};
|
||
|
||
const url = _assetUrl(window.CB_ASSET_UPDATE_BASE_URL, assetId);
|
||
try {
|
||
const resp = await _csrfPost(url, payload);
|
||
const data = await resp.json();
|
||
if (data.success) {
|
||
_showFeedback('cb-edit-single-feedback', _gt('Modifications enregistrées.'));
|
||
loadData(); // refresh map
|
||
} else {
|
||
_showFeedback('cb-edit-single-feedback', data.error || _gt('Erreur'), true);
|
||
}
|
||
} catch (err) {
|
||
_showFeedback('cb-edit-single-feedback', _gt('Erreur réseau'), true);
|
||
}
|
||
}
|
||
|
||
async function _deleteCurrentAsset() {
|
||
const assetId = document.getElementById('cb-edit-asset-id')?.value;
|
||
if (!assetId) return;
|
||
if (!confirm(_gt('Archiver cet asset ? Cette action est réversible via l\'interface d\'administration.'))) return;
|
||
|
||
const url = _assetUrl(window.CB_ASSET_DELETE_BASE_URL, assetId);
|
||
try {
|
||
const resp = await _csrfPost(url, {});
|
||
const data = await resp.json();
|
||
if (data.success) {
|
||
_removeDragMarker();
|
||
setSelected(null);
|
||
_showEditPanel('empty');
|
||
loadData();
|
||
} else {
|
||
alert(data.error || _gt('Erreur'));
|
||
}
|
||
} catch (err) {
|
||
alert(_gt('Erreur réseau'));
|
||
}
|
||
}
|
||
|
||
/* ── Bulk update ─────────────────────────────────────────────────────── */
|
||
|
||
async function _saveBulkAssets(e) {
|
||
e.preventDefault();
|
||
if (_bulkSelectedIds.size === 0) return;
|
||
|
||
const updates = {};
|
||
const status = document.getElementById('cb-bulk-status')?.value;
|
||
const mainFn = document.getElementById('cb-bulk-main-function')?.value;
|
||
const action = document.getElementById('cb-bulk-action')?.value;
|
||
const priority = document.getElementById('cb-bulk-priority')?.value;
|
||
const dateVal = document.getElementById('cb-bulk-target-date')?.value;
|
||
|
||
if (status) updates.status = status;
|
||
if (mainFn) updates.main_function = mainFn;
|
||
if (action) updates.recommended_action = action;
|
||
if (priority !== '') updates.priority_level = priority || null;
|
||
if (dateVal) updates.targeted_action_date = dateVal;
|
||
|
||
if (!Object.keys(updates).length) {
|
||
_showFeedback('cb-edit-bulk-feedback', _gt('Aucun champ renseigné.'), true);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const resp = await _csrfPost(window.CB_BULK_UPDATE_URL, {
|
||
asset_ids: [..._bulkSelectedIds],
|
||
updates,
|
||
});
|
||
const data = await resp.json();
|
||
if (data.success) {
|
||
_showFeedback('cb-edit-bulk-feedback',
|
||
_ip(_ngt('%(count)s asset mis à jour.', '%(count)s assets mis à jour.', data.updated),
|
||
{ count: data.updated }, true));
|
||
loadData();
|
||
} else {
|
||
_showFeedback('cb-edit-bulk-feedback', data.error || _gt('Erreur'), true);
|
||
}
|
||
} catch (err) {
|
||
_showFeedback('cb-edit-bulk-feedback', _gt('Erreur réseau'), true);
|
||
}
|
||
}
|
||
|
||
/* ── Create asset ────────────────────────────────────────────────────── */
|
||
|
||
async function _createAsset(e) {
|
||
e.preventDefault();
|
||
const secFns = [...document.querySelectorAll('.cb-create-sec-fn:checked')].map(cb => cb.value);
|
||
const payload = {
|
||
code: document.getElementById('cb-create-code')?.value || null,
|
||
address: document.getElementById('cb-create-address')?.value || null,
|
||
main_function: document.getElementById('cb-create-main-function')?.value || null,
|
||
secondary_functions: secFns.length ? secFns : null,
|
||
number_of_blocks: document.getElementById('cb-create-nb-blocks')?.value || null,
|
||
length: document.getElementById('cb-create-length')?.value || null,
|
||
recommended_action: document.getElementById('cb-create-action')?.value || null,
|
||
priority_level: document.getElementById('cb-create-priority')?.value || null,
|
||
targeted_action_date: document.getElementById('cb-create-target-date')?.value || null,
|
||
lon: document.getElementById('cb-create-lon')?.value || null,
|
||
lat: document.getElementById('cb-create-lat')?.value || null,
|
||
};
|
||
try {
|
||
const resp = await _csrfPost(window.CB_ASSET_CREATE_URL, payload);
|
||
const data = await resp.json();
|
||
if (data.success) {
|
||
_showFeedback('cb-create-feedback',
|
||
_ip(_gt('Asset %(code)s créé.'), { code: data.code || data.id }, true));
|
||
// Reset form
|
||
document.getElementById('cb-form-create')?.reset();
|
||
_removeDragMarker();
|
||
loadData();
|
||
} else {
|
||
_showFeedback('cb-create-feedback', data.error || _gt('Erreur'), true);
|
||
}
|
||
} catch (err) {
|
||
_showFeedback('cb-create-feedback', _gt('Erreur réseau'), true);
|
||
}
|
||
}
|
||
|
||
/* ── Rubber-band selection ───────────────────────────────────────────── */
|
||
|
||
let _rbStart = null;
|
||
const _rbEl = document.getElementById('cb-rubber-band');
|
||
|
||
function _getRubberBandContainer() {
|
||
return document.querySelector('.cb-map-container') || document.getElementById('cb-map');
|
||
}
|
||
|
||
function _onRbMouseDown(e) {
|
||
if (!_selectMode) return;
|
||
if (e.button !== 0) return;
|
||
// Only start rubber band if click is on map canvas, not on a feature
|
||
if (e.target !== cbMap.getCanvas()) return;
|
||
|
||
const rect = cbMap.getCanvas().getBoundingClientRect();
|
||
const mapContainer = _getRubberBandContainer();
|
||
const cRect = mapContainer.getBoundingClientRect();
|
||
|
||
_rbStart = {
|
||
x: e.clientX - cRect.left,
|
||
y: e.clientY - cRect.top,
|
||
clientX: e.clientX,
|
||
clientY: e.clientY,
|
||
mapX: e.clientX - rect.left,
|
||
mapY: e.clientY - rect.top,
|
||
};
|
||
|
||
if (_rbEl) {
|
||
_rbEl.style.left = `${_rbStart.x}px`;
|
||
_rbEl.style.top = `${_rbStart.y}px`;
|
||
_rbEl.style.width = '0px';
|
||
_rbEl.style.height = '0px';
|
||
_rbEl.style.display = '';
|
||
}
|
||
// Prevent map panning while drawing selection
|
||
cbMap.dragPan.disable();
|
||
}
|
||
|
||
function _onRbMouseMove(e) {
|
||
if (!_rbStart || !_rbEl) return;
|
||
const mapContainer = _getRubberBandContainer();
|
||
const cRect = mapContainer.getBoundingClientRect();
|
||
const curX = e.clientX - cRect.left;
|
||
const curY = e.clientY - cRect.top;
|
||
|
||
_rbEl.style.left = `${Math.min(_rbStart.x, curX)}px`;
|
||
_rbEl.style.top = `${Math.min(_rbStart.y, curY)}px`;
|
||
_rbEl.style.width = `${Math.abs(curX - _rbStart.x)}px`;
|
||
_rbEl.style.height = `${Math.abs(curY - _rbStart.y)}px`;
|
||
}
|
||
|
||
function _onRbMouseUp(e) {
|
||
if (!_rbStart) return;
|
||
if (_rbEl) _rbEl.style.display = 'none';
|
||
cbMap.dragPan.enable();
|
||
|
||
const rect = cbMap.getCanvas().getBoundingClientRect();
|
||
const endMapX = e.clientX - rect.left;
|
||
const endMapY = e.clientY - rect.top;
|
||
const startMapX = _rbStart.mapX;
|
||
const startMapY = _rbStart.mapY;
|
||
|
||
const dx = Math.abs(endMapX - startMapX);
|
||
const dy = Math.abs(endMapY - startMapY);
|
||
|
||
// Only perform area selection if the box is meaningful (> 5 px)
|
||
if (dx > 5 || dy > 5) {
|
||
_selectFeaturesInBox(startMapX, startMapY, endMapX, endMapY);
|
||
}
|
||
_rbStart = null;
|
||
}
|
||
|
||
function _attachRubberBandListeners() {
|
||
const canvas = cbMap.getCanvas();
|
||
canvas.addEventListener('mousedown', _onRbMouseDown);
|
||
window.addEventListener('mousemove', _onRbMouseMove);
|
||
window.addEventListener('mouseup', _onRbMouseUp);
|
||
}
|
||
|
||
/* ── Pick-position mode (click on map to set new position) ──────────── */
|
||
|
||
function _enterPickPositionMode(context) {
|
||
_pickPositionMode = context;
|
||
cbMap.getCanvas().style.cursor = 'crosshair';
|
||
}
|
||
|
||
function _exitPickPositionMode() {
|
||
_pickPositionMode = null;
|
||
cbMap.getCanvas().style.cursor = _selectMode ? 'crosshair' : '';
|
||
}
|
||
|
||
/* ── Modify map click behaviour for edit modes ──────────────────────── */
|
||
|
||
/**
|
||
* This function patches the existing map click behaviour.
|
||
* It is called once after cbMap.on('load', ...) completes.
|
||
*/
|
||
function _patchMapForEditing() {
|
||
// Pick-position click (overrides default handlers)
|
||
cbMap.on('click', (e) => {
|
||
if (!_pickPositionMode) return;
|
||
e.originalEvent._cbFeatureClicked = true; // prevent deselect
|
||
const { lng, lat } = e.lngLat;
|
||
if (_pickPositionMode === 'edit') {
|
||
const setVal = (id, v) => { const el = document.getElementById(id); if (el) el.value = v; };
|
||
setVal('cb-edit-lon', Number(lng).toFixed(6));
|
||
setVal('cb-edit-lat', Number(lat).toFixed(6));
|
||
_placeDragMarker(lng, lat, (nLng, nLat) => {
|
||
setVal('cb-edit-lon', Number(nLng).toFixed(6));
|
||
setVal('cb-edit-lat', Number(nLat).toFixed(6));
|
||
});
|
||
} else if (_pickPositionMode === 'create') {
|
||
const setVal = (id, v) => { const el = document.getElementById(id); if (el) el.value = v; };
|
||
setVal('cb-create-lon', Number(lng).toFixed(6));
|
||
setVal('cb-create-lat', Number(lat).toFixed(6));
|
||
}
|
||
_exitPickPositionMode();
|
||
});
|
||
|
||
// Override feature click behaviour in edit / select modes
|
||
['cb-inner', 'cb-inner-symbols'].forEach(layerId => {
|
||
cbMap.on('click', layerId, (e) => {
|
||
if (!e.features?.length) return;
|
||
e.originalEvent._cbFeatureClicked = true;
|
||
|
||
// Pick-position mode: ignore feature clicks
|
||
if (_pickPositionMode) return;
|
||
|
||
const feat = e.features[0];
|
||
const props = feat.properties;
|
||
|
||
if (_selectMode && window.CB_CAN_EDIT) {
|
||
// Multi-select mode: reconstruct enriched feature from cache
|
||
const id = props.asset_id;
|
||
const cached = currentFeatures.find(f => f.properties.asset_id === id) || feat;
|
||
_toggleBulkSelect(cached);
|
||
return; // do not show detail or single-edit
|
||
}
|
||
|
||
// Normal click while edit tab is active: show single-edit form
|
||
const editTab = document.getElementById('edit-tab');
|
||
const editPane = document.getElementById('edit-panel');
|
||
const isEditActive = editTab?.classList.contains('active') ||
|
||
editPane?.classList.contains('show');
|
||
|
||
if (isEditActive && window.CB_CAN_EDIT) {
|
||
const id = props.asset_id;
|
||
const cached = currentFeatures.find(f => f.properties.asset_id === id) || feat;
|
||
_bulkSelectedIds.clear();
|
||
_bulkSelectedFeats.clear();
|
||
_bulkSelectedIds.add(id);
|
||
_bulkSelectedFeats.set(id, cached);
|
||
_refreshMultiSelectLayer();
|
||
_openSingleEdit(id, cached);
|
||
}
|
||
// (normal detail-panel click is handled by existing listeners)
|
||
});
|
||
});
|
||
|
||
_attachRubberBandListeners();
|
||
}
|
||
|
||
/* ── Init all edit UI event bindings ────────────────────────────────── */
|
||
|
||
function initEditUI() {
|
||
if (!window.CB_CAN_EDIT && !window.CB_CAN_CREATE) return;
|
||
|
||
// Wait for map load to initialise the multi-select layer & patch handlers
|
||
cbMap.on('load', () => {
|
||
_initMultiSelectLayer();
|
||
_patchMapForEditing();
|
||
});
|
||
// If map already loaded
|
||
if (cbMap.loaded()) {
|
||
_initMultiSelectLayer();
|
||
_patchMapForEditing();
|
||
}
|
||
|
||
// When the edit tab becomes active, pre-load the last selected asset
|
||
document.getElementById('edit-tab')?.addEventListener('show.bs.tab', () => {
|
||
if (_lastDetailId && window.CB_CAN_EDIT && !_selectMode) {
|
||
_bulkSelectedIds.clear();
|
||
_bulkSelectedFeats.clear();
|
||
if (_lastDetailFeat) {
|
||
_bulkSelectedIds.add(_lastDetailId);
|
||
_bulkSelectedFeats.set(_lastDetailId, _lastDetailFeat);
|
||
_refreshMultiSelectLayer();
|
||
}
|
||
_openSingleEdit(_lastDetailId, _lastDetailFeat);
|
||
}
|
||
});
|
||
|
||
// Select mode toggle
|
||
document.getElementById('cb-btn-select-mode')?.addEventListener('click', () => {
|
||
_selectMode = !_selectMode;
|
||
const btn = document.getElementById('cb-btn-select-mode');
|
||
if (btn) {
|
||
btn.classList.toggle('active', _selectMode);
|
||
btn.classList.toggle('btn-outline-secondary', !_selectMode);
|
||
btn.classList.toggle('btn-secondary', _selectMode);
|
||
}
|
||
cbMap.getCanvas().style.cursor = _selectMode ? 'crosshair' : '';
|
||
if (!_selectMode) {
|
||
// Clear selection when leaving select mode
|
||
_bulkSelectedIds.clear();
|
||
_bulkSelectedFeats.clear();
|
||
_refreshMultiSelectLayer();
|
||
_showEditPanel('empty');
|
||
}
|
||
});
|
||
|
||
// Clear selection
|
||
document.getElementById('cb-btn-clear-selection')?.addEventListener('click', () => {
|
||
_bulkSelectedIds.clear();
|
||
_bulkSelectedFeats.clear();
|
||
_refreshMultiSelectLayer();
|
||
_showEditPanel('empty');
|
||
});
|
||
|
||
// Add asset button
|
||
document.getElementById('cb-btn-add')?.addEventListener('click', () => {
|
||
_removeDragMarker();
|
||
_showEditPanel('create');
|
||
document.getElementById('cb-form-create')?.reset();
|
||
});
|
||
|
||
// Single edit form save
|
||
document.getElementById('cb-form-single')?.addEventListener('submit', _saveSingleAsset);
|
||
|
||
// Delete button
|
||
document.getElementById('cb-btn-delete-asset')?.addEventListener('click', _deleteCurrentAsset);
|
||
|
||
// Cancel single edit
|
||
document.getElementById('cb-btn-cancel-edit')?.addEventListener('click', () => {
|
||
_removeDragMarker();
|
||
_bulkSelectedIds.clear();
|
||
_bulkSelectedFeats.clear();
|
||
_refreshMultiSelectLayer();
|
||
_showEditPanel('empty');
|
||
});
|
||
|
||
// Bulk form save
|
||
document.getElementById('cb-form-bulk')?.addEventListener('submit', _saveBulkAssets);
|
||
|
||
// Cancel bulk
|
||
document.getElementById('cb-btn-cancel-bulk')?.addEventListener('click', () => {
|
||
_bulkSelectedIds.clear();
|
||
_bulkSelectedFeats.clear();
|
||
_refreshMultiSelectLayer();
|
||
_showEditPanel('empty');
|
||
});
|
||
|
||
// Create form save
|
||
document.getElementById('cb-form-create')?.addEventListener('submit', _createAsset);
|
||
|
||
// Cancel create
|
||
document.getElementById('cb-btn-cancel-create')?.addEventListener('click', () => {
|
||
_removeDragMarker();
|
||
_showEditPanel('empty');
|
||
});
|
||
|
||
// Pick position buttons
|
||
document.getElementById('cb-btn-pick-position')?.addEventListener('click', () => {
|
||
_enterPickPositionMode('edit');
|
||
});
|
||
document.getElementById('cb-btn-pick-create-position')?.addEventListener('click', () => {
|
||
_enterPickPositionMode('create');
|
||
});
|
||
|
||
// Pressing Escape cancels pick-position mode
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape') {
|
||
_exitPickPositionMode();
|
||
}
|
||
});
|
||
}
|