Legends: clip tall ones and link to the full image; add the coastlines legend

The coastlines layer showed no legend because it was added without a legendUrl,
so _registerLegend was never called. The provider does serve one: GeoServer
renders it on demand through GetLegendGraphic. It is 230 x 2110 px with roughly
105 entries — one shoreline per year plus the rates-of-change classes.

Nothing sensible happens if that is scaled into a 220 px panel. Two columns
halves the label size, four columns without labels is not a legend, and one
column scaled to fit is a 2,000 px scroll. So the image is now shown at natural
size inside a frame capped at 200 px with overflow hidden: the first entries
stay legible at full size, a gradient fades the cut edge, and a caption invites
opening the full legend, which the frame links to in a new tab where neither the
panel geometry nor the cross-origin policy applies. A legend that fits is left
exactly as it was — no fade, no caption. Verified in a browser against both: the
2110 px one clips with fade and caption, the 125 px slope legend does not.

That host sends neither Access-Control-Allow-Origin nor
Cross-Origin-Resource-Policy, so under require-corp both the direct and the CORS
path fail and it has to go through legend.php. It is allow-listed there with a
note on which header each DEAfrica host is missing, since that is what decides
whether the proxy is needed. Verified live: 35,802 bytes of image/png.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ekke 2026-10-21 14:28:12 +02:00
parent b7e79dc778
commit 8c859197ad
12 changed files with 77 additions and 30 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
dist/assets/index-CPKm3M4d.js.map vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,4 +1,4 @@
import{d as f,z as d}from"./index-DY549L0n.js";import"./vite-helpers-BlTxHScW.js";import"./bootstrap-D1-uvFxm.js";import"./openlayers-D5-VSKzq.js";import"./ol-ext-DV70ljNY.js";function p(e){return String(e).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&apos;").replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g,"")}function h(e){let l="",o=e;for(;o>=0;)l=String.fromCharCode(o%26+65)+l,o=Math.floor(o/26)-1;return l}function x(e){return e&&!Array.isArray(e)&&Array.isArray(e.cells)?{cells:e.cells,bold:!!e.bold}:{cells:Array.isArray(e)?e:[e],bold:!1}}function u(e){const l=[];return e.forEach((o,n)=>{const{cells:t,bold:a}=x(o),r=a?' s="1"':"",i=t.map((s,c)=>{const m=`${h(c)}${n+1}`;return s==null||s===""?`<c r="${m}"${r}/>`:typeof s=="number"&&Number.isFinite(s)?`<c r="${m}"${r}><v>${s}</v></c>`:`<c r="${m}"${r} t="inlineStr"><is><t xml:space="preserve">${p(s)}</t></is></c>`}).join("");l.push(`<row r="${n+1}">${i}</row>`)}),`<?xml version="1.0" encoding="UTF-8" standalone="yes"?> import{d as f,z as d}from"./index-CPKm3M4d.js";import"./vite-helpers-BlTxHScW.js";import"./bootstrap-D1-uvFxm.js";import"./openlayers-D5-VSKzq.js";import"./ol-ext-DV70ljNY.js";function p(e){return String(e).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&apos;").replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g,"")}function h(e){let l="",o=e;for(;o>=0;)l=String.fromCharCode(o%26+65)+l,o=Math.floor(o/26)-1;return l}function x(e){return e&&!Array.isArray(e)&&Array.isArray(e.cells)?{cells:e.cells,bold:!!e.bold}:{cells:Array.isArray(e)?e:[e],bold:!1}}function u(e){const l=[];return e.forEach((o,n)=>{const{cells:t,bold:a}=x(o),r=a?' s="1"':"",i=t.map((s,c)=>{const m=`${h(c)}${n+1}`;return s==null||s===""?`<c r="${m}"${r}/>`:typeof s=="number"&&Number.isFinite(s)?`<c r="${m}"${r}><v>${s}</v></c>`:`<c r="${m}"${r} t="inlineStr"><is><t xml:space="preserve">${p(s)}</t></is></c>`}).join("");l.push(`<row r="${n+1}">${i}</row>`)}),`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>${l.join("")}</sheetData></worksheet>`}const y=`<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>${l.join("")}</sheetData></worksheet>`}const y=`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"> <styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><b/><sz val="11"/><name val="Calibri"/></font></fonts> <fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><b/><sz val="11"/><name val="Calibri"/></font></fonts>
@ -27,4 +27,4 @@ ${n.map(t=>`<Relationship Id="rId${t.id}" Type="http://schemas.openxmlformats.or
`)} `)}
<Relationship Id="rIdStyles" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/> <Relationship Id="rIdStyles" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
</Relationships>`,o["xl/styles.xml"]=y,d(o,"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")}function S(e,l){f(g(e),l)}export{g as buildXlsxBlob,S as exportXlsx}; </Relationships>`,o["xl/styles.xml"]=y,d(o,"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")}function S(e,l){f(g(e),l)}export{g as buildXlsxBlob,S as exportXlsx};
//# sourceMappingURL=xlsx-Xgu_2_hC.js.map //# sourceMappingURL=xlsx-CE8N38FZ.js.map

File diff suppressed because one or more lines are too long

2
dist/index.html vendored
View File

@ -2004,7 +2004,7 @@
} }
</style> </style>
<script type="module" crossorigin src="/assets/index-DY549L0n.js"></script> <script type="module" crossorigin src="/assets/index-CPKm3M4d.js"></script>
<link rel="modulepreload" crossorigin href="/assets/vite-helpers-BlTxHScW.js"> <link rel="modulepreload" crossorigin href="/assets/vite-helpers-BlTxHScW.js">
<link rel="modulepreload" crossorigin href="/assets/bootstrap-D1-uvFxm.js"> <link rel="modulepreload" crossorigin href="/assets/bootstrap-D1-uvFxm.js">
<link rel="modulepreload" crossorigin href="/assets/openlayers-D5-VSKzq.js"> <link rel="modulepreload" crossorigin href="/assets/openlayers-D5-VSKzq.js">

3
dist/legend.php vendored
View File

@ -31,7 +31,8 @@ declare(strict_types=1);
/** Hosts whose legend images may be proxied. */ /** Hosts whose legend images may be proxied. */
const ALLOWED_HOSTS = [ const ALLOWED_HOSTS = [
'ows.digitalearth.africa', 'ows.digitalearth.africa', // datacube-ows — sends ACAO but no CORP
'geoserver.digitalearth.africa', // GeoServer — sends neither, so needs this proxy
]; ];
const MAX_BYTES = 2097152; // 2 MB — legends are a few KB; this is a sanity cap const MAX_BYTES = 2097152; // 2 MB — legends are a few KB; this is a sanity cap

13
main.js
View File

@ -2339,7 +2339,18 @@ function loadExternalWMSLayers() {
'DEAfrica Coastlines v0.4', 'DEAfrica Coastlines v0.4',
'https://geoserver.digitalearth.africa/geoserver/wms', 'https://geoserver.digitalearth.africa/geoserver/wms',
'coastlines:DEAfrica_Coastlines', 'coastlines:DEAfrica_Coastlines',
{ serverType: 'geoserver', visible: false, onlineOnly: true } {
serverType: 'geoserver',
visible: false,
onlineOnly: true,
// GeoServer renders this on demand. It is a tall legend — roughly 105
// entries, one shoreline per year plus the rates-of-change classes — so
// the panel clips it and links to the full image. This host sends neither
// CORS nor CORP headers, so it is served through /legend.php.
legendUrl:
'https://geoserver.digitalearth.africa/geoserver/wms?service=WMS&version=1.1.0' +
'&request=GetLegendGraphic&format=image/png&layer=coastlines:DEAfrica_Coastlines',
}
); );
// Note: OpenTopoMap is available as the "Topographic" base map — // Note: OpenTopoMap is available as the "Topographic" base map —

View File

@ -31,7 +31,8 @@ declare(strict_types=1);
/** Hosts whose legend images may be proxied. */ /** Hosts whose legend images may be proxied. */
const ALLOWED_HOSTS = [ const ALLOWED_HOSTS = [
'ows.digitalearth.africa', 'ows.digitalearth.africa', // datacube-ows — sends ACAO but no CORP
'geoserver.digitalearth.africa', // GeoServer — sends neither, so needs this proxy
]; ];
const MAX_BYTES = 2097152; // 2 MB — legends are a few KB; this is a sanity cap const MAX_BYTES = 2097152; // 2 MB — legends are a few KB; this is a sanity cap

View File

@ -5209,30 +5209,64 @@ export class MapView {
heading.textContent = title; heading.textContent = title;
wrapper.appendChild(heading); wrapper.appendChild(heading);
// Loading a legend here is not as simple as setting src. The app is // A legend can be any size the provider feels like: the DEAfrica coastlines
// cross-origin isolated (Cross-Origin-Embedder-Policy: require-corp), so a // legend is 230 x 2110 px with about 105 entries. Scaling that down to fit
// cross-origin image is blocked unless the provider sends // makes the labels unreadable, so it is shown at natural size inside a
// Cross-Origin-Resource-Policy — which most OGC servers do not. A CORS // capped, clipped frame — the first entries stay legible — and the frame
// request satisfies COEP on its own, so try the provider directly with // links to the full image, which opens in a tab where neither the panel
// crossorigin first: that is how the base-map tiles already work. If it // geometry nor the cross-origin policy applies.
// still fails, fall back once to /legend.php, which fetches the image const frame = document.createElement('a');
// server-side and serves it from our own origin, where no cross-origin frame.href = legendUrl;
// policy applies. Only after both fail is the entry hidden. frame.target = '_blank';
frame.rel = 'noopener noreferrer';
frame.title = `Open the full ${title} legend`;
frame.style.cssText =
'display:block;position:relative;max-height:200px;overflow:hidden;' +
'border-radius:3px;text-decoration:none;color:inherit;';
// Loading the image is not as simple as setting src. The app is cross-origin
// isolated (Cross-Origin-Embedder-Policy: require-corp), so a cross-origin
// image is blocked unless the provider sends Cross-Origin-Resource-Policy —
// which most OGC servers do not. A CORS request satisfies COEP on its own, so
// try the provider directly with crossorigin first: that is how the base-map
// tiles already work. If it still fails, fall back once to /legend.php, which
// fetches the image server-side and serves it from our own origin. Only after
// both fail is the entry hidden.
const img = document.createElement('img'); const img = document.createElement('img');
img.alt = `${title} legend`; img.alt = `${title} legend`;
img.crossOrigin = 'anonymous'; img.crossOrigin = 'anonymous';
img.style.cssText = 'display:block;max-width:100%;height:auto;border-radius:3px;'; img.style.cssText = 'display:block;max-width:100%;height:auto;';
img.addEventListener('error', () => { img.addEventListener('error', () => {
if (img.dataset.viaProxy === '1') { if (img.dataset.viaProxy === '1') {
img.style.display = 'none'; wrapper.style.display = 'none'; // hide the entry, not just the image
return; return;
} }
img.dataset.viaProxy = '1'; img.dataset.viaProxy = '1';
img.removeAttribute('crossorigin'); // same-origin from here, so CORS is moot img.removeAttribute('crossorigin'); // same-origin from here, so CORS is moot
img.src = `/legend.php?url=${encodeURIComponent(legendUrl)}`; img.src = `/legend.php?url=${encodeURIComponent(legendUrl)}`;
}); });
// Only once it has loaded can we tell whether it overflows the frame.
img.addEventListener('load', () => {
wrapper.style.display = '';
if (frame.scrollHeight <= frame.clientHeight + 1) return;
const fade = document.createElement('div');
fade.style.cssText =
'position:absolute;left:0;right:0;bottom:0;height:34px;pointer-events:none;' +
'background:linear-gradient(to bottom, transparent, var(--card, #fff));';
frame.appendChild(fade);
const more = document.createElement('div');
more.style.cssText =
'font-size:10px;margin-top:3px;color:var(--muted-foreground, #7a7a7a);';
more.textContent = 'Open the full legend \u2197';
wrapper.appendChild(more);
});
img.src = legendUrl; img.src = legendUrl;
wrapper.appendChild(img); frame.appendChild(img);
wrapper.appendChild(frame);
this._legendEntries.set(layer, wrapper); this._legendEntries.set(layer, wrapper);