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

1614 lines
68 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

/**
* 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();
}
});
}