feat: add robust panel type image normalization, text fallback rendering, and corresponding styles

This commit is contained in:
kdeterme 2026-09-14 16:37:34 +02:00
parent dcf5477199
commit 9ae7b0631b
5 changed files with 467 additions and 33 deletions

View file

@ -385,6 +385,168 @@
};
}
function normalizePanelTypeCodeForImage(code) {
let normalized = String(code || '').trim();
if (!normalized) return '';
const upper = normalized.toUpperCase();
if (upper === 'XA') {
normalized = 'GXA';
} else if (upper === 'XB') {
normalized = 'GXB';
} else if (upper === 'XD') {
normalized = 'GXD';
} else if (upper.startsWith('Z') && upper.length > 1) {
normalized = normalized.slice(1);
}
return normalized.replace(/["']/g, '').trim();
}
function buildPanelTypeImageCandidates(code) {
const normalizedCode = normalizePanelTypeCodeForImage(code);
if (!normalizedCode) return [];
const upperCode = normalizedCode.toUpperCase();
const baseSet = new Set([upperCode]);
const strippedPrefix = upperCode.replace(/^[-_]+/, '');
if (strippedPrefix) {
baseSet.add(strippedPrefix);
baseSet.add(strippedPrefix.replace(/-/g, '_'));
}
if (upperCode.includes(' ')) {
baseSet.add(upperCode.replace(/\s+/g, ''));
baseSet.add(upperCode.replace(/\s+/g, '_'));
}
if (upperCode.includes('-')) {
baseSet.add(upperCode.replace(/-/g, '_'));
}
const uniqueCodes = [];
baseSet.forEach(item => {
if (!item) return;
const up = item.toUpperCase();
const title = up.charAt(0) + up.slice(1).toLowerCase();
const low = up.toLowerCase();
[up, title, low].forEach(c => {
if (!uniqueCodes.includes(c)) uniqueCodes.push(c);
});
});
const candidates = [];
uniqueCodes.forEach(item => {
candidates.push(`/static/assets/road_signs/2025/${encodeURIComponent(item)}.svg`);
});
uniqueCodes.forEach(item => {
candidates.push(`/static/assets/road_signs/2025/${encodeURIComponent(item)}.png`);
});
return candidates;
}
function renderPanelTextFallback(container, code, options = {}) {
if (!container) return;
const text = String(code || '').trim();
if (!text) {
container.innerHTML = '';
return;
}
const len = text.length;
let fontSize;
let lineHeight = '1.1';
if (len <= 4) {
fontSize = '0.72rem';
lineHeight = '1.15';
} else if (len <= 8) {
fontSize = '0.62rem';
lineHeight = '1.1';
} else if (len <= 14) {
fontSize = '0.54rem';
lineHeight = '1.05';
} else if (len <= 24) {
fontSize = '0.48rem';
lineHeight = '1.05';
} else {
fontSize = '0.42rem';
lineHeight = '1.0';
}
const isSmallThumb = options.width && parseInt(options.width, 10) < 35;
if (isSmallThumb) {
fontSize = len <= 4 ? '0.55rem' : '0.40rem';
}
container.innerHTML = '';
const fallbackBox = document.createElement('div');
fallbackBox.className = 'panel-text-fallback';
fallbackBox.title = text;
const span = document.createElement('span');
span.className = 'panel-fallback-text';
span.style.fontSize = fontSize;
span.style.lineHeight = lineHeight;
span.textContent = text;
fallbackBox.appendChild(span);
container.appendChild(fallbackBox);
}
function appendPanelTypeImage(container, code, imageUrl, options = {}) {
if (!container) return;
const imageHeight = options.height || '40px';
const imageWidth = options.width || '40px';
const objectFit = options.objectFit || 'contain';
function createImage(src) {
const img = document.createElement('img');
img.src = src;
img.alt = code || '';
img.style.maxHeight = '100%';
img.style.maxWidth = '100%';
img.style.height = imageHeight;
img.style.width = imageWidth;
img.style.objectFit = objectFit;
return img;
}
const candidates = buildPanelTypeImageCandidates(code);
let candidateIndex = 0;
function tryFallbackCandidate() {
const src = candidates[candidateIndex++];
if (!src) {
renderPanelTextFallback(container, code, options);
return;
}
const fallbackImg = createImage(src);
fallbackImg.onerror = tryFallbackCandidate;
container.innerHTML = '';
container.appendChild(fallbackImg);
}
if (imageUrl) {
const img = createImage(imageUrl);
img.onerror = tryFallbackCandidate;
container.innerHTML = '';
container.appendChild(img);
return;
}
tryFallbackCandidate();
}
global.normalizePanelTypeCodeForImage = normalizePanelTypeCodeForImage;
global.buildPanelTypeImageCandidates = buildPanelTypeImageCandidates;
global.renderPanelTextFallback = renderPanelTextFallback;
global.appendPanelTypeImage = appendPanelTypeImage;
global.SignPanelsShared = {
getRenderedPanelHeight,
calculatePanelOffsets,
@ -392,5 +554,9 @@
handleRoadSignStyleImageMissing,
refreshPanelOffsetsForSource,
createPanelOffsetRefreshScheduler,
normalizePanelTypeCodeForImage,
buildPanelTypeImageCandidates,
renderPanelTextFallback,
appendPanelTypeImage,
};
})(window);

View file

@ -3127,11 +3127,35 @@ function buildPanelTypeImageCandidates(code) {
if (!normalizedCode) return [];
const upperCode = normalizedCode.toUpperCase();
const titleCode = upperCode.charAt(0) + upperCode.slice(1).toLowerCase();
const lowerCode = upperCode.toLowerCase();
const uniqueCodes = Array.from(new Set([upperCode, titleCode, lowerCode]));
const candidates = [];
const baseSet = new Set([upperCode]);
const strippedPrefix = upperCode.replace(/^[-_]+/, '');
if (strippedPrefix) {
baseSet.add(strippedPrefix);
baseSet.add(strippedPrefix.replace(/-/g, '_'));
}
if (upperCode.includes(' ')) {
baseSet.add(upperCode.replace(/\s+/g, ''));
baseSet.add(upperCode.replace(/\s+/g, '_'));
}
if (upperCode.includes('-')) {
baseSet.add(upperCode.replace(/-/g, '_'));
}
const uniqueCodes = [];
baseSet.forEach(item => {
if (!item) return;
const up = item.toUpperCase();
const title = up.charAt(0) + up.slice(1).toLowerCase();
const low = up.toLowerCase();
[up, title, low].forEach(c => {
if (!uniqueCodes.includes(c)) uniqueCodes.push(c);
});
});
const candidates = [];
uniqueCodes.forEach(item => {
candidates.push(`/static/assets/road_signs/2025/${encodeURIComponent(item)}.svg`);
});
@ -3142,6 +3166,55 @@ function buildPanelTypeImageCandidates(code) {
return candidates;
}
function renderPanelTextFallback(container, code, options = {}) {
if (!container) return;
const text = String(code || '').trim();
if (!text) {
container.innerHTML = '';
return;
}
const len = text.length;
let fontSize;
let lineHeight = '1.1';
if (len <= 4) {
fontSize = '0.72rem';
lineHeight = '1.15';
} else if (len <= 8) {
fontSize = '0.62rem';
lineHeight = '1.1';
} else if (len <= 14) {
fontSize = '0.54rem';
lineHeight = '1.05';
} else if (len <= 24) {
fontSize = '0.48rem';
lineHeight = '1.05';
} else {
fontSize = '0.42rem';
lineHeight = '1.0';
}
const isSmallThumb = options.width && parseInt(options.width, 10) < 35;
if (isSmallThumb) {
fontSize = len <= 4 ? '0.55rem' : '0.40rem';
}
container.innerHTML = '';
const fallbackBox = document.createElement('div');
fallbackBox.className = 'panel-text-fallback';
fallbackBox.title = text;
const span = document.createElement('span');
span.className = 'panel-fallback-text';
span.style.fontSize = fontSize;
span.style.lineHeight = lineHeight;
span.textContent = text;
fallbackBox.appendChild(span);
container.appendChild(fallbackBox);
}
function appendPanelTypeImage(container, code, imageUrl, options = {}) {
if (!container) return;
@ -3167,7 +3240,7 @@ function appendPanelTypeImage(container, code, imageUrl, options = {}) {
function tryFallbackCandidate() {
const src = candidates[candidateIndex++];
if (!src) {
container.textContent = code || '';
renderPanelTextFallback(container, code, options);
return;
}
@ -3233,6 +3306,8 @@ function renderSignPanelTypeConditionalFilter(thematicCode, def, selectedValues)
#accordion-sign-panel-types .panel-item {
transition: all 0.2s cubic-bezier(0.165, 0.84, 0.44, 1);
border: 1px solid #dee2e6 !important;
overflow: hidden !important;
box-sizing: border-box !important;
}
#accordion-sign-panel-types .panel-item:hover {
transform: translateY(-3px);
@ -3242,6 +3317,55 @@ function renderSignPanelTypeConditionalFilter(thematicCode, def, selectedValues)
#accordion-sign-panel-types .panel-item.bg-primary {
box-shadow: 0 4px 12px rgba(13, 110, 253, 0.15) !important;
}
#accordion-sign-panel-types .panel-img-placeholder {
width: 100% !important;
max-width: 100% !important;
overflow: hidden !important;
box-sizing: border-box !important;
padding: 2px !important;
}
#accordion-sign-panel-types .panel-text-fallback,
.panel-text-fallback {
width: 100%;
height: 100%;
max-height: 44px;
display: flex;
align-items: center;
justify-content: center;
background-color: #f8f9fa;
border: 1px dashed #d0d5dd;
border-radius: 4px;
padding: 2px 3px;
box-sizing: border-box;
overflow: hidden;
transition: background-color 0.15s ease, border-color 0.15s ease;
}
#accordion-sign-panel-types .panel-fallback-text,
.panel-fallback-text {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 4;
line-clamp: 4;
max-height: 40px;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-word;
overflow-wrap: anywhere;
text-align: center;
font-weight: 600;
color: #495057;
letter-spacing: -0.2px;
}
#accordion-sign-panel-types .panel-item.bg-primary .panel-text-fallback,
#accordion-sign-panel-types .panel-item.selected .panel-text-fallback {
background-color: rgba(255, 255, 255, 0.2) !important;
border-color: rgba(255, 255, 255, 0.4) !important;
}
#accordion-sign-panel-types .panel-item.bg-primary .panel-fallback-text,
#accordion-sign-panel-types .panel-item.selected .panel-fallback-text {
color: #ffffff !important;
}
</style>
`;
@ -3257,11 +3381,11 @@ function renderSignPanelTypeConditionalFilter(thematicCode, def, selectedValues)
data-id="${escapeHtml(option.value)}"
data-code="${escapeHtml(option.code)}"
data-image-url="${escapeHtml(option.imageUrl)}"
style="cursor: pointer; width: 70px;"
style="cursor: pointer; width: 70px; overflow: hidden; box-sizing: border-box;"
title="${escapeHtml(title)}">
<div class="panel-img-placeholder mb-1"
style="height: 50px; display: flex; align-items: center; justify-content: center;"></div>
<div class="small text-truncate fw-bold" style="font-size: 0.7rem;">
style="height: 50px; width: 100%; display: flex; align-items: center; justify-content: center; overflow: hidden; box-sizing: border-box;"></div>
<div class="small text-truncate fw-bold" style="font-size: 0.7rem; width: 100%; max-width: 100%; overflow: hidden;">
${escapeHtml(option.code)}
</div>
</div>
@ -3373,21 +3497,24 @@ function initSignPanelTypePickers() {
item.className = 'selected-panel-item d-flex justify-content-between align-items-center bg-white border rounded p-1 mb-1';
const content = document.createElement('div');
content.className = 'd-flex align-items-center';
content.className = 'd-flex align-items-center text-truncate me-2';
content.style.maxWidth = 'calc(100% - 30px)';
const thumb = document.createElement('div');
thumb.className = 'd-flex align-items-center justify-content-center me-2';
thumb.className = 'd-flex align-items-center justify-content-center me-2 flex-shrink-0';
thumb.style.width = '28px';
thumb.style.height = '28px';
thumb.style.overflow = 'hidden';
appendPanelTypeImage(thumb, code, imageUrl, { width: '28px', height: '28px' });
const label = document.createElement('span');
label.className = 'small fw-bold ms-1';
label.className = 'small fw-bold ms-1 text-truncate';
label.title = code;
label.textContent = code;
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'btn btn-sm btn-link text-danger p-0 me-1 remove-panel-type';
removeBtn.className = 'btn btn-sm btn-link text-danger p-0 me-1 remove-panel-type flex-shrink-0';
removeBtn.dataset.id = id;
removeBtn.innerHTML = '<i class="bi bi-x"></i>';

View file

@ -2370,27 +2370,41 @@ document.addEventListener('DOMContentLoaded', function() {
const imageUrl = panelItem.dataset.imageUrl;
const panelCode = panelItem.dataset.code;
if (imageUrl) {
imageHtml = `<img src="${imageUrl}" style="height: 40px; width: 40px; object-fit: contain; margin-right: 8px;">`;
const thumbContainer = document.createElement('div');
thumbContainer.style.width = '40px';
thumbContainer.style.height = '40px';
thumbContainer.style.marginRight = '8px';
thumbContainer.style.display = 'flex';
thumbContainer.style.alignItems = 'center';
thumbContainer.style.justifyContent = 'center';
thumbContainer.style.overflow = 'hidden';
thumbContainer.style.flexShrink = '0';
if (typeof appendPanelTypeImage === 'function') {
appendPanelTypeImage(thumbContainer, panelCode, imageUrl, { width: '40px', height: '40px' });
} else if (imageUrl) {
thumbContainer.innerHTML = `<img src="${imageUrl}" style="height: 40px; width: 40px; object-fit: contain;">`;
} else if (typeof buildPanelIconElement === 'function') {
const img = buildPanelIconElement(panelCode);
img.style.height = '40px';
img.style.width = '40px';
img.style.marginRight = '8px';
const tempDiv = document.createElement('div');
tempDiv.appendChild(img);
imageHtml = tempDiv.innerHTML;
thumbContainer.appendChild(img);
} else if (typeof renderPanelTextFallback === 'function') {
renderPanelTextFallback(thumbContainer, panelCode, { width: '40px', height: '40px' });
} else {
imageHtml = `<span style="font-size: 0.8rem; margin-right: 8px;">${panelCode}</span>`;
thumbContainer.innerHTML = `<span style="font-size: 0.8rem;">${panelCode}</span>`;
}
const tempWrapper = document.createElement('div');
tempWrapper.appendChild(thumbContainer);
imageHtml = tempWrapper.innerHTML;
}
const item = document.createElement('div');
item.className = 'd-flex align-items-center bg-white border rounded p-2';
item.innerHTML = `
${imageHtml}
<span class="fw-bold">${code}</span>
<button type="button" class="btn btn-sm btn-link text-danger p-0 ms-auto remove-panel-type-create">
<span class="fw-bold text-truncate me-2" style="max-width: 240px;" title="${code}">${code}</span>
<button type="button" class="btn btn-sm btn-link text-danger p-0 ms-auto remove-panel-type-create flex-shrink-0">
<i class="bi bi-x"></i>
</button>
`;
@ -2423,7 +2437,9 @@ document.addEventListener('DOMContentLoaded', function() {
const imageUrl = item.dataset.imageUrl;
const code = item.dataset.code;
if (imageUrl) {
if (typeof appendPanelTypeImage === 'function') {
appendPanelTypeImage(placeholder, code, imageUrl);
} else if (imageUrl) {
const img = document.createElement('img');
img.src = imageUrl;
img.style.maxHeight = '100%';
@ -2434,6 +2450,8 @@ document.addEventListener('DOMContentLoaded', function() {
img.style.height = '40px';
img.style.width = '40px';
placeholder.appendChild(img);
} else if (typeof renderPanelTextFallback === 'function') {
renderPanelTextFallback(placeholder, code);
} else {
placeholder.textContent = code;
}

View file

@ -124,6 +124,8 @@
border: 1px solid #e9ecef !important;
border-radius: 6px !important;
background-color: #ffffff !important;
overflow: hidden !important;
box-sizing: border-box !important;
}
.panel-item:hover, .panel-item-create:hover {
@ -137,6 +139,65 @@
background-color: rgba(13, 110, 253, 0.05) !important;
box-shadow: 0 4px 12px rgba(13, 110, 253, 0.1) !important;
}
.panel-img-placeholder {
width: 100% !important;
max-width: 100% !important;
overflow: hidden !important;
box-sizing: border-box !important;
padding: 2px !important;
}
.panel-text-fallback {
width: 100%;
height: 100%;
max-height: 44px;
display: flex;
align-items: center;
justify-content: center;
background-color: #f8f9fa;
border: 1px dashed #d0d5dd;
border-radius: 4px;
padding: 2px 3px;
box-sizing: border-box;
overflow: hidden;
transition: background-color 0.15s ease, border-color 0.15s ease;
}
.panel-fallback-text {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 4;
line-clamp: 4;
max-height: 40px;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-word;
overflow-wrap: anywhere;
text-align: center;
font-weight: 600;
color: #495057;
letter-spacing: -0.2px;
}
.panel-item.selected .panel-text-fallback,
.panel-item-create.selected .panel-text-fallback,
.panel-item.bg-primary .panel-text-fallback,
.panel-item-create.bg-primary .panel-text-fallback {
background-color: rgba(13, 110, 253, 0.12) !important;
border-color: rgba(13, 110, 253, 0.35) !important;
}
.panel-item.selected .panel-fallback-text,
.panel-item-create.selected .panel-fallback-text {
color: #0d6efd !important;
}
.panel-item.bg-primary .panel-fallback-text,
.panel-item-create.bg-primary .panel-fallback-text {
color: #ffffff !important;
}
</style>
{% endblock head %}
@ -306,12 +367,12 @@
data-code="{{ panel.code }}"
data-group="{{ group_key }}"
data-image-url="{{ panel.image_url|default:'' }}"
style="cursor: pointer; width: 68px; transition: transform 0.15s ease;"
style="cursor: pointer; width: 68px; overflow: hidden; box-sizing: border-box; transition: transform 0.15s ease;"
title="{{ panel.code }} - {{ panel.name }}">
<div class="panel-img-placeholder mb-1" style="height: 48px; display: flex; align-items: center; justify-content: center;">
<div class="panel-img-placeholder mb-1" style="height: 48px; width: 100%; display: flex; align-items: center; justify-content: center; overflow: hidden; box-sizing: border-box;">
<!-- Content will be injected by JS -->
</div>
<div class="small text-truncate fw-bold text-muted" style="font-size: 0.65rem;">{{ panel.code }}</div>
<div class="small text-truncate fw-bold text-muted" style="font-size: 0.65rem; width: 100%; max-width: 100%; overflow: hidden;">{{ panel.code }}</div>
</div>
{% endfor %}
{% endif %}
@ -2016,7 +2077,9 @@ document.addEventListener('DOMContentLoaded', function() {
if (placeholder && placeholder.children.length === 0) {
const imageUrl = item.dataset.imageUrl;
const code = item.dataset.code;
if (imageUrl) {
if (typeof appendPanelTypeImage === 'function') {
appendPanelTypeImage(placeholder, code, imageUrl);
} else if (imageUrl) {
const img = document.createElement('img');
img.src = imageUrl;
img.style.maxHeight = '100%';
@ -2027,6 +2090,8 @@ document.addEventListener('DOMContentLoaded', function() {
img.style.height = '40px';
img.style.width = '40px';
placeholder.appendChild(img);
} else if (typeof renderPanelTextFallback === 'function') {
renderPanelTextFallback(placeholder, code);
} else {
placeholder.textContent = code;
}
@ -2177,8 +2242,8 @@ document.addEventListener('DOMContentLoaded', function() {
const item = document.createElement('div');
item.className = 'd-flex justify-content-between align-items-center bg-white border rounded p-1 mb-1 shadow-sm';
item.innerHTML = `
<span class="small fw-bold ms-1 text-primary">${code}</span>
<button type="button" class="btn btn-sm btn-link text-danger p-0 me-1 remove-panel-type" data-id="${id}" title="Retirer">
<span class="small fw-bold ms-1 text-primary text-truncate" style="max-width: 200px;" title="${code}">${code}</span>
<button type="button" class="btn btn-sm btn-link text-danger p-0 me-1 remove-panel-type flex-shrink-0" data-id="${id}" title="Retirer">
<i class="bi bi-x"></i>
</button>
`;
@ -2195,8 +2260,8 @@ document.addEventListener('DOMContentLoaded', function() {
const item = document.createElement('div');
item.className = 'd-flex justify-content-between align-items-center bg-white border rounded p-1 mb-1 shadow-sm';
item.innerHTML = `
<span class="small fw-bold ms-1 text-primary">${code}</span>
<button type="button" class="btn btn-sm btn-link text-danger p-0 me-1 remove-panel-type" data-id="${id}" title="Retirer">
<span class="small fw-bold ms-1 text-primary text-truncate" style="max-width: 200px;" title="${code}">${code}</span>
<button type="button" class="btn btn-sm btn-link text-danger p-0 me-1 remove-panel-type flex-shrink-0" data-id="${id}" title="Retirer">
<i class="bi bi-x"></i>
</button>
`;

View file

@ -85,6 +85,8 @@
border: 1px solid #e9ecef !important;
border-radius: 6px !important;
background-color: #ffffff !important;
overflow: hidden !important;
box-sizing: border-box !important;
}
.panel-item:hover, .panel-item-create:hover {
@ -92,6 +94,62 @@
border-color: #0d6efd !important;
box-shadow: 0 4px 12px rgba(13, 110, 253, 0.15) !important;
}
.panel-img-placeholder {
width: 100% !important;
max-width: 100% !important;
overflow: hidden !important;
box-sizing: border-box !important;
padding: 2px !important;
}
.panel-text-fallback {
width: 100%;
height: 100%;
max-height: 44px;
display: flex;
align-items: center;
justify-content: center;
background-color: #f8f9fa;
border: 1px dashed #d0d5dd;
border-radius: 4px;
padding: 2px 3px;
box-sizing: border-box;
overflow: hidden;
transition: background-color 0.15s ease, border-color 0.15s ease;
}
.panel-fallback-text {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 4;
line-clamp: 4;
max-height: 40px;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-word;
overflow-wrap: anywhere;
text-align: center;
font-weight: 600;
color: #495057;
letter-spacing: -0.2px;
}
.panel-item.bg-primary .panel-text-fallback,
.panel-item-create.bg-primary .panel-text-fallback,
.panel-item.selected .panel-text-fallback,
.panel-item-create.selected .panel-text-fallback {
background-color: rgba(255, 255, 255, 0.2) !important;
border-color: rgba(255, 255, 255, 0.4) !important;
}
.panel-item.bg-primary .panel-fallback-text,
.panel-item-create.bg-primary .panel-fallback-text,
.panel-item.selected .panel-fallback-text,
.panel-item-create.selected .panel-fallback-text {
color: #ffffff !important;
}
</style>
<!-- Accordion for panel categories -->
@ -119,12 +177,12 @@
data-id="{{ panel.id }}"
data-code="{{ panel.code }}"
data-image-url="{{ panel.image_url|default:'' }}"
style="cursor: pointer; width: 68px; transition: transform 0.15s ease;"
style="cursor: pointer; width: 68px; overflow: hidden; box-sizing: border-box; transition: transform 0.15s ease;"
title="{{ panel.code }} - {{ panel.name }}">
<div class="panel-img-placeholder mb-1" style="height: 48px; display: flex; align-items: center; justify-content: center;">
<div class="panel-img-placeholder mb-1" style="height: 48px; width: 100%; display: flex; align-items: center; justify-content: center; overflow: hidden; box-sizing: border-box;">
<!-- Content will be injected by JS -->
</div>
<div class="small text-truncate fw-bold text-muted" style="font-size: 0.65rem;">{{ panel.code }}</div>
<div class="small text-truncate fw-bold text-muted" style="font-size: 0.65rem; width: 100%; max-width: 100%; overflow: hidden;">{{ panel.code }}</div>
</div>
{% endfor %}
{% endif %}