Compare commits

...
27 Commits
Author SHA1 Message Date
jfig 16383c2a3f feat: add multilingual interface support 2026-07-24 16:00:12 +01:00
jfig d2567c59ce feat: add share controls 2026-07-24 12:24:16 +01:00
jfig a478f8972f feat: add application toast messages 2026-07-24 09:54:19 +01:00
jfig 8d4f003b43 feat: support shareable map views 2026-07-24 09:43:03 +01:00
jfig ae95447dc8 feat: improve feature modal navigation 2026-07-24 04:19:17 +01:00
jfig bf44014dc1 feat: indicate clickable map features 2026-07-24 03:51:25 +01:00
jfig 2510bc583d feat: allow dragging feature dialog 2026-07-24 03:46:44 +01:00
jfig fadd2af414 fix: make feature modal translucent 2026-07-24 03:33:12 +01:00
jfig 46efce287d fix: improve small-device drawer layout 2026-07-24 03:21:02 +01:00
jfig f8964a8183 chore: add safe republish workflow 2026-07-23 11:56:34 +01:00
jfig 9449bf9b93 fix: disable compass when resetting north 2026-07-23 02:32:47 +01:00
jfig 623838c861 fix: clarify compass orientation state 2026-07-23 02:29:12 +01:00
jfig 1d4d09ff5e fix: hide dwelling layer control 2026-07-23 02:19:22 +01:00
jfig a0566b1225 fix: collapse sidebar on small-screen load 2026-07-23 02:17:19 +01:00
jfig 3a7bfbf0c2 feat: highlight dwelling parcels 2026-07-23 01:44:30 +01:00
jfig f4e55349ca feat: add device compass map orientation 2026-07-22 10:32:29 +01:00
jfig ff8d55f1c0 feat: add area zoom map control 2026-07-22 09:44:25 +01:00
jfig 93ac14941e fix: simplify parcel search labels 2026-07-22 08:10:25 +01:00
jfig 1e25cfc575 feat: support parcel sub-number search 2026-07-22 07:54:33 +01:00
jfig c60840e4c1 feat: show feature information on map click 2026-07-22 07:21:03 +01:00
jfig ec922c22ff chore: add area zoom icon asset 2026-07-22 07:01:58 +01:00
jfig f76b553fc4 feat: improve map zoom controls 2026-07-21 12:28:07 +01:00
jfig 5851517a6b fix: preserve map view when toggling layers 2026-07-21 12:09:45 +01:00
jfig f706128e9c feat: add north reset control 2026-07-21 12:02:48 +01:00
jfig 41ded45b09 feat: add collapsible map controls 2026-07-21 03:02:49 +01:00
jfig 4ddb7fc2d8 feat: add satellite imagery background 2026-07-21 01:42:54 +01:00
jfig 0e09c67460 feat: add selectable map backgrounds 2026-07-20 01:48:06 +01:00
13 changed files with 2954 additions and 84 deletions
+2
View File
@@ -7,8 +7,10 @@ RUN npm ci --omit=dev
COPY server.js ./ COPY server.js ./
COPY index.html ./ COPY index.html ./
COPY i18n.js ./
COPY app.js ./ COPY app.js ./
COPY styles.css ./ COPY styles.css ./
COPY assets ./assets
ENV HOST=0.0.0.0 ENV HOST=0.0.0.0
ENV PORT=4173 ENV PORT=4173
+20
View File
@@ -62,3 +62,23 @@ ALLOWED_WMS_ENDPOINTS=https://xpro-viz.jfig.net/geoserver/xpro/wms,https://examp
- The proxy only allows configured WMS endpoints. By default it allows the XPro GeoServer endpoint. - The proxy only allows configured WMS endpoints. By default it allows the XPro GeoServer endpoint.
- It fetches WMS capabilities with backend HTTP Basic Auth and loads all discovered layers on top of OpenStreetMap. - It fetches WMS capabilities with backend HTTP Basic Auth and loads all discovered layers on top of OpenStreetMap.
- The currently published layers discovered during setup were `parcelas` and `predios`. - The currently published layers discovered during setup were `parcelas` and `predios`.
## Sharing a map view
The URL updates as the map is moved, zoomed, rotated, or its visible layers or
base map are changed. An open parcel or building information dialog is included
too. Copy the URL to share that exact state; opening it restores the same map
view and selected feature. Invalid or incomplete map parameters fall back to
the normal default view.
## Translations
The interface selects English, Spanish, or Portuguese from the browser
preference on the first visit and remembers subsequent changes made in
Settings. Unsupported languages and missing messages fall back to English.
To add another language:
1. Add a dictionary with the same keys to `TRANSLATIONS` in `i18n.js`.
2. Translate every value, including accessibility labels and status messages.
3. Run `npm test` to verify that the dictionary is complete.
+1377 -39
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="51.110092" height="17.62863" viewBox="0 0 51.110092 17.62863">
<g transform="translate(-23.402038 -175.28522) matrix(1.729104 0 0 1.729104 -47.763707 -130.17409)">
<path fill="#1574c3" d="m 51.662106,186.67656 h 1.857378 v -3.80966 h 0.59653 c 1.423537,0 2.074297,-0.10846 2.630155,-0.4474 0.81345,-0.51518 1.315077,-1.51844 1.315077,-2.67082 0,-1.15239 -0.48807,-2.10142 -1.35575,-2.63016 -0.569415,-0.33894 -1.24729,-0.46095 -2.562367,-0.46095 h -2.481023 z m 1.857378,-5.58569 v -2.65727 h 0.623645 c 0.610087,0 0.894795,0.0271 1.179502,0.0949 0.5423,0.14913 0.86768,0.62365 0.86768,1.24729 0,0.48807 -0.21692,0.86768 -0.610087,1.0846 -0.284708,0.14913 -0.786335,0.23048 -1.51844,0.23048 z m 5.500524,5.58569 h 1.803148 v -4.17571 c -0.04067,-1.11172 0.528742,-1.73536 1.613342,-1.77603 v -1.73536 h -0.135575 c -0.772777,0 -1.152387,0.21692 -1.6269,0.90835 v -0.73211 h -1.654015 z m 7.683272,-7.6871 c -2.182758,0 -3.972348,1.77603 -3.972348,3.93167 0,2.1692 1.78959,3.93168 3.985905,3.93168 2.182758,0 3.999463,-1.76248 3.999463,-3.87745 0,-2.23699 -1.748918,-3.9859 -4.01302,-3.9859 z m 0.01356,1.65401 c 1.206618,0 2.182758,1.01681 2.182758,2.27766 0,1.26085 -0.97614,2.27766 -2.1692,2.27766 -1.220175,0 -2.182758,-1.01681 -2.182758,-2.30477 0,-1.23374 0.97614,-2.25055 2.1692,-2.25055 z"/>
<path fill="#f8a621" d="m 41.157585,186.71507 h 2.209873 l 2.33189,-3.45716 2.399677,3.45716 h 2.155643 l -3.47072,-4.98916 3.47072,-5.02983 h -2.155643 l -2.399677,3.49784 -2.33189,-3.49784 h -2.209873 l 3.443605,5.02983 z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<path d="M0 0h24v24H0z" fill="none" />
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13v4m-2-2h4m-7 0a5 5 0 1 0 10 0a5 5 0 1 0-10 0m12 7l-3-3M6 18H5a2 2 0 0 1-2-2v-1m0-4v-1m0-4V5a2 2 0 0 1 2-2h1m4 0h1m4 0h1a2 2 0 0 1 2 2v1" />
</svg>

After

Width:  |  Height:  |  Size: 389 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 56 56" aria-hidden="true">
<path d="M0 0h56v56H0z" fill="none" />
<path fill="#cf3d36" d="M11.992 52.375c1.172 0 1.922-.422 3.07-1.547l12.61-12.492c.117-.117.21-.211.328-.211c.117 0 .211.094.328.21l12.61 12.493c1.148 1.125 1.898 1.547 3.07 1.547c1.57 0 2.554-1.219 2.554-2.812c0-.891-.374-1.946-.726-2.907L31.188 6.625c-.75-2.062-1.852-3-3.188-3s-2.437.938-3.187 3L10.164 46.656c-.351.961-.726 2.016-.726 2.907c0 1.593.984 2.812 2.554 2.812m1.617-5.11c-.07-.046-.093-.14-.023-.28L27.766 8.71c.07-.188.14-.258.234-.258s.164.07.234.258l14.18 38.273c.07.141.047.235-.023.282c-.047.023-.141 0-.211-.047L29.172 34.023c-.422-.422-.844-.515-1.172-.515c-.352 0-.75.093-1.172.515L13.82 47.22c-.07.047-.164.07-.21.047" />
</svg>

After

Width:  |  Height:  |  Size: 776 B

+358
View File
@@ -0,0 +1,358 @@
(function initializeI18n(globalScope) {
"use strict";
const DEFAULT_LANGUAGE = "en";
const STORAGE_KEY = "xprov-language";
const TRANSLATIONS = {
en: {
languageName: "English",
pageTitle: "XPro Map Visualizer",
applicationMessages: "Application messages",
openMapControls: "Open map controls",
mapControls: "Map controls",
closeMapControls: "Close map controls",
mapVisualizer: "Map Visualizer",
openSettings: "Open settings",
closeSettings: "Close settings",
settings: "Settings",
applySettings: "Apply",
useBrowserDefault: "Use browser default",
find: "Find",
parcelExample: "e.g. 57 or 2669.1",
parcelNumber: "Parcel number",
findOnMap: "Find on map",
layers: "Layers",
waitingForConnection: "Waiting for connection.",
loadingPublishedLayers: "Loading published layers.",
baseMap: "Base map",
softMinimal: "Soft minimal",
openStreetMap: "OpenStreetMap",
satelliteImagery: "Satellite imagery",
language: "Language",
applicationLanguage: "Application language",
mapView: "Map view",
closeFeatureInformation: "Close feature information",
resetNorth: "Reset map orientation to north",
zoomVisibleLayers: "Zoom to visible layers",
copyMapLink: "Copy link to this map view",
clipboardUnavailable: "Clipboard access is unavailable.",
mapLinkCopied: "Link to this map view copied to your clipboard.",
mapLinkCopyFailed:
"Unable to copy the map link. Please copy the address from your browser.",
zoomArea: "Zoom to area",
exitAreaZoom: "Exit area zoom (Esc)",
compassRequiresHttps: "Device compass requires HTTPS; HTTP works only on localhost.",
compassUnavailable: "Device compass is unavailable on this device.",
compassPermissionDenied: "Device compass permission was not granted.",
compassOn: "Device compass orientation is on.",
compassStartFailed: "Unable to start the device compass.",
compassOff: "Device compass orientation is off.",
compassTurnOff: "Turn off device compass orientation",
compassAlign: "Align map with device compass",
dismissMessage: "Dismiss message",
enterParcel: "Enter a parcel number to search.",
searchingParcel: "Searching for parcel…",
searchFailedStatus: "Parcel search failed (status {status}).",
noParcelFound: "No parcel found with number “{parcelNumber}”.",
foundParcel: "Found parcel {parcelNumber}.",
foundParcels: "Found {count} parcels matching {parcelNumber}.",
parcelSearchFailed: "Unable to search for that parcel.",
loadingWmsLayers: "Loading WMS layers…",
wmsLoadFailed: "Unable to load the WMS service.",
geoserverConnectionFailed: "GeoServer connection failed (status {status}).",
noPublishedLayers: "No published layers were returned by the service.",
selectLayerToZoom: "Select a layer with a published extent to zoom to it.",
parcel: "Parcel",
building: "Building",
area: "Area",
areaUnavailable: "Area unavailable",
inhabited: "Inhabited",
viewCorrespondingBuilding: "View corresponding building",
associatedParcels: "Associated parcels",
viewParcel: "View parcel {parcelNumber}",
parcelWithNumber: "Parcel {parcelNumber}",
unavailable: "Unavailable",
noAssociatedParcels: "No associated parcels were found.",
zoomIn: "Zoom in",
zoomOut: "Zoom out",
attribution: "Attributions",
softAttribution:
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>',
satelliteAttribution:
'Tiles © <a href="https://www.esri.com/en-us/legal/terms/full-master-agreement">Esri</a> — Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community',
},
es: {
languageName: "Español",
pageTitle: "Visualizador de Mapas XPro",
applicationMessages: "Mensajes de la aplicación",
openMapControls: "Abrir controles del mapa",
mapControls: "Controles del mapa",
closeMapControls: "Cerrar controles del mapa",
mapVisualizer: "Visualizador de Mapas",
openSettings: "Abrir configuración",
closeSettings: "Cerrar configuración",
settings: "Configuración",
applySettings: "Aplicar",
useBrowserDefault: "Usar el idioma predeterminado del navegador",
find: "Buscar",
parcelExample: "p. ej., 57 o 2669.1",
parcelNumber: "Número de parcela",
findOnMap: "Localizar en el mapa",
layers: "Capas",
waitingForConnection: "Esperando conexión.",
loadingPublishedLayers: "Cargando capas publicadas.",
baseMap: "Mapa base",
softMinimal: "Minimalista suave",
openStreetMap: "OpenStreetMap",
satelliteImagery: "Imágenes por satélite",
language: "Idioma",
applicationLanguage: "Idioma de la aplicación",
mapView: "Vista del mapa",
closeFeatureInformation: "Cerrar información del elemento",
resetNorth: "Restablecer la orientación del mapa hacia el norte",
zoomVisibleLayers: "Ajustar a las capas visibles",
copyMapLink: "Copiar enlace a esta vista del mapa",
clipboardUnavailable: "El acceso al portapapeles no está disponible.",
mapLinkCopied: "El enlace a esta vista se ha copiado al portapapeles.",
mapLinkCopyFailed:
"No se pudo copiar el enlace. Copie la dirección desde el navegador.",
zoomArea: "Ampliar un área",
exitAreaZoom: "Salir de la ampliación de área (Esc)",
compassRequiresHttps:
"La brújula del dispositivo requiere HTTPS; HTTP solo funciona en localhost.",
compassUnavailable: "La brújula no está disponible en este dispositivo.",
compassPermissionDenied: "No se concedió permiso para usar la brújula.",
compassOn: "La orientación mediante la brújula está activada.",
compassStartFailed: "No se pudo iniciar la brújula del dispositivo.",
compassOff: "La orientación mediante la brújula está desactivada.",
compassTurnOff: "Desactivar la orientación mediante la brújula",
compassAlign: "Alinear el mapa con la brújula",
dismissMessage: "Descartar mensaje",
enterParcel: "Introduzca un número de parcela para buscar.",
searchingParcel: "Buscando la parcela…",
searchFailedStatus: "La búsqueda de la parcela falló (estado {status}).",
noParcelFound: "No se encontró la parcela con el número «{parcelNumber}».",
foundParcel: "Se encontró la parcela {parcelNumber}.",
foundParcels: "Se encontraron {count} parcelas que coinciden con {parcelNumber}.",
parcelSearchFailed: "No se pudo buscar esa parcela.",
loadingWmsLayers: "Cargando capas WMS…",
wmsLoadFailed: "No se pudo cargar el servicio WMS.",
geoserverConnectionFailed: "La conexión con GeoServer falló (estado {status}).",
noPublishedLayers: "El servicio no devolvió ninguna capa publicada.",
selectLayerToZoom: "Seleccione una capa con extensión publicada para encuadrarla.",
parcel: "Parcela",
building: "Edificio",
area: "Área",
areaUnavailable: "Área no disponible",
inhabited: "Habitada",
viewCorrespondingBuilding: "Ver el edificio correspondiente",
associatedParcels: "Parcelas asociadas",
viewParcel: "Ver parcela {parcelNumber}",
parcelWithNumber: "Parcela {parcelNumber}",
unavailable: "No disponible",
noAssociatedParcels: "No se encontraron parcelas asociadas.",
zoomIn: "Acercar",
zoomOut: "Alejar",
attribution: "Atribuciones",
softAttribution:
'© Colaboradores de <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <a href="https://carto.com/attributions">CARTO</a>',
satelliteAttribution:
'Mosaicos © <a href="https://www.esri.com/en-us/legal/terms/full-master-agreement">Esri</a> — Fuente: Esri, Maxar, Earthstar Geographics y la comunidad de usuarios de SIG',
},
"pt-PT": {
languageName: "Português",
pageTitle: "Visualizador de Mapas XPro",
applicationMessages: "Mensagens da aplicação",
openMapControls: "Abrir controlos do mapa",
mapControls: "Controlos do mapa",
closeMapControls: "Fechar controlos do mapa",
mapVisualizer: "Visualizador de Mapas",
openSettings: "Abrir definições",
closeSettings: "Fechar definições",
settings: "Definições",
applySettings: "Aplicar",
useBrowserDefault: "Usar predefinição do navegador",
find: "Pesquisar",
parcelExample: "p. ex. 57 ou 2669.1",
parcelNumber: "Número da parcela",
findOnMap: "Localizar no mapa",
layers: "Camadas",
waitingForConnection: "A aguardar ligação.",
loadingPublishedLayers: "A carregar camadas publicadas.",
baseMap: "Mapa base",
softMinimal: "Minimalista suave",
openStreetMap: "OpenStreetMap",
satelliteImagery: "Imagem de satélite",
language: "Idioma",
applicationLanguage: "Idioma da aplicação",
mapView: "Vista do mapa",
closeFeatureInformation: "Fechar informação do elemento",
resetNorth: "Repor a orientação do mapa para norte",
zoomVisibleLayers: "Ajustar às camadas visíveis",
copyMapLink: "Copiar ligação para esta vista do mapa",
clipboardUnavailable: "O acesso à área de transferência não está disponível.",
mapLinkCopied: "A ligação para esta vista foi copiada para a área de transferência.",
mapLinkCopyFailed:
"Não foi possível copiar a ligação. Copie o endereço a partir do navegador.",
zoomArea: "Ampliar uma área",
exitAreaZoom: "Sair da ampliação de área (Esc)",
compassRequiresHttps:
"A bússola do dispositivo requer HTTPS; HTTP funciona apenas em localhost.",
compassUnavailable: "A bússola não está disponível neste dispositivo.",
compassPermissionDenied: "A permissão para usar a bússola não foi concedida.",
compassOn: "A orientação pela bússola está ativa.",
compassStartFailed: "Não foi possível iniciar a bússola do dispositivo.",
compassOff: "A orientação pela bússola está desativada.",
compassTurnOff: "Desativar orientação pela bússola",
compassAlign: "Alinhar mapa com a bússola",
dismissMessage: "Dispensar mensagem",
enterParcel: "Introduza um número de parcela para pesquisar.",
searchingParcel: "A pesquisar a parcela…",
searchFailedStatus: "A pesquisa da parcela falhou (estado {status}).",
noParcelFound: "Não foi encontrada a parcela com o número “{parcelNumber}”.",
foundParcel: "Parcela {parcelNumber} encontrada.",
foundParcels: "Foram encontradas {count} parcelas correspondentes a {parcelNumber}.",
parcelSearchFailed: "Não foi possível pesquisar essa parcela.",
loadingWmsLayers: "A carregar camadas WMS…",
wmsLoadFailed: "Não foi possível carregar o serviço WMS.",
geoserverConnectionFailed: "A ligação ao GeoServer falhou (estado {status}).",
noPublishedLayers: "O serviço não devolveu camadas publicadas.",
selectLayerToZoom: "Selecione uma camada com extensão publicada para a enquadrar.",
parcel: "Parcela",
building: "Prédio",
area: "Área",
areaUnavailable: "Área indisponível",
inhabited: "Habitada",
viewCorrespondingBuilding: "Ver prédio correspondente",
associatedParcels: "Parcelas associadas",
viewParcel: "Ver parcela {parcelNumber}",
parcelWithNumber: "Parcela {parcelNumber}",
unavailable: "Indisponível",
noAssociatedParcels: "Não foram encontradas parcelas associadas.",
zoomIn: "Ampliar",
zoomOut: "Reduzir",
attribution: "Atribuições",
softAttribution:
'© Colaboradores do <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <a href="https://carto.com/attributions">CARTO</a>',
satelliteAttribution:
'Mosaicos © <a href="https://www.esri.com/en-us/legal/terms/full-master-agreement">Esri</a> — Fonte: Esri, Maxar, Earthstar Geographics e comunidade de utilizadores de SIG',
},
};
function normalizeLanguage(language) {
if (typeof language !== "string") {
return null;
}
const normalized = language.replace("_", "-").toLowerCase();
return Object.keys(TRANSLATIONS).find((candidate) => {
const candidateNormalized = candidate.toLowerCase();
return candidateNormalized === normalized || candidateNormalized.split("-")[0] === normalized.split("-")[0];
}) || null;
}
function readStoredLanguage(storage) {
try {
return normalizeLanguage(storage?.getItem(STORAGE_KEY));
} catch {
return null;
}
}
function detectLanguage({ storage, languages = [] } = {}) {
const storedLanguage = readStoredLanguage(storage);
if (storedLanguage) {
return storedLanguage;
}
for (const language of languages) {
const supportedLanguage = normalizeLanguage(language);
if (supportedLanguage) {
return supportedLanguage;
}
}
return DEFAULT_LANGUAGE;
}
function detectBrowserLanguage(languages = []) {
for (const language of languages) {
const supportedLanguage = normalizeLanguage(language);
if (supportedLanguage) {
return supportedLanguage;
}
}
return DEFAULT_LANGUAGE;
}
function createI18n({
storage = globalScope.localStorage,
languages = globalScope.navigator?.languages || [globalScope.navigator?.language],
} = {}) {
const browserLanguage = detectBrowserLanguage(languages);
let preference = readStoredLanguage(storage);
let language = preference || browserLanguage;
function translate(key, values = {}) {
const template = TRANSLATIONS[language]?.[key]
?? TRANSLATIONS[DEFAULT_LANGUAGE]?.[key]
?? key;
return template.replace(/\{(\w+)\}/g, (match, name) =>
Object.hasOwn(values, name) ? String(values[name]) : match,
);
}
return {
get language() {
return language;
},
get locale() {
return language;
},
get preference() {
return preference;
},
get supportedLanguages() {
return Object.keys(TRANSLATIONS);
},
setLanguage(nextLanguage) {
const useBrowserDefault = nextLanguage == null || nextLanguage === "browser";
preference = useBrowserDefault
? null
: normalizeLanguage(nextLanguage) || DEFAULT_LANGUAGE;
language = preference || browserLanguage;
try {
if (preference) {
storage?.setItem(STORAGE_KEY, preference);
} else {
storage?.removeItem(STORAGE_KEY);
}
} catch {
// A blocked storage API must not prevent language selection.
}
return language;
},
t: translate,
tp(singularKey, pluralKey, count, values = {}) {
return translate(count === 1 ? singularKey : pluralKey, { ...values, count });
},
};
}
const exported = {
DEFAULT_LANGUAGE,
STORAGE_KEY,
TRANSLATIONS,
normalizeLanguage,
detectLanguage,
detectBrowserLanguage,
createI18n,
};
if (typeof module !== "undefined" && module.exports) {
module.exports = exported;
}
globalScope.XProI18n = exported;
})(typeof window === "undefined" ? globalThis : window);
+139 -23
View File
@@ -3,57 +3,173 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>XPro Map Visualizer</title> <title data-i18n="pageTitle">XPro Map Visualizer</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,400,0,0" />
<link rel="stylesheet" href="./vendor/ol.css" /> <link rel="stylesheet" href="./vendor/ol.css" />
<link rel="stylesheet" href="./styles.css" /> <link rel="stylesheet" href="./styles.css" />
</head> </head>
<body> <body>
<div class="app-shell"> <div class="app-shell">
<aside class="sidebar"> <div
<div class="brand"> id="message-region"
<h1>XPro Map Visualizer</h1> class="message-region"
</div> aria-label="Application messages"
data-i18n-aria-label="applicationMessages"
aria-live="polite"
aria-relevant="additions"
></div>
<button
id="sidebar-open"
class="sidebar-toggle sidebar-open"
type="button"
aria-controls="sidebar"
aria-expanded="true"
>
<span aria-hidden="true"></span>
<span class="visually-hidden" data-i18n="openMapControls">Open map controls</span>
</button>
<aside
id="sidebar"
class="sidebar"
aria-label="Map controls"
data-i18n-aria-label="mapControls"
>
<button id="sidebar-close" class="sidebar-toggle sidebar-close" type="button">
<span aria-hidden="true">×</span>
<span class="visually-hidden" data-i18n="closeMapControls">Close map controls</span>
</button>
<div class="sidebar-content">
<div class="brand">
<h1>
<img class="brand-logo" src="./assets/brand/xpro-logo-simple.svg" alt="XPro" />
<span class="visually-hidden">XPro</span>
</h1>
<div class="brand-footer">
<p class="brand-subtitle" data-i18n="mapVisualizer">Map Visualizer</p>
<button
id="settings-open"
class="settings-open"
type="button"
aria-label="Open settings"
data-i18n-aria-label="openSettings"
>
<span class="material-symbols-outlined" aria-hidden="true">settings</span>
</button>
</div>
</div>
<section class="panel"> <section class="panel">
<div class="panel-heading"> <div class="panel-heading">
<h2>Find parcel</h2> <h2 data-i18n="find">Find</h2>
</div> </div>
<form id="parcel-search-form" class="parcel-search-form"> <form id="parcel-search-form" class="parcel-search-form">
<label for="parcel-search-input"> <input
<span>Parcel number</span> id="parcel-search-input"
<input name="parcel"
id="parcel-search-input" type="search"
name="parcel" inputmode="text"
type="search" placeholder="e.g. 57 or 2669.1"
inputmode="text" data-i18n-placeholder="parcelExample"
placeholder="e.g. 57 or 2669.1" autocomplete="off"
autocomplete="off" aria-label="Parcel number"
required data-i18n-aria-label="parcelNumber"
/> required
</label> />
<button type="submit">Find on map</button> <button type="submit" data-i18n="findOnMap">Find on map</button>
</form> </form>
<p id="parcel-search-status" class="status" aria-live="polite"></p> <p id="parcel-search-status" class="status" aria-live="polite"></p>
</section> </section>
<section class="panel"> <section class="panel">
<div class="panel-heading"> <div class="panel-heading">
<h2>Layers</h2> <h2 data-i18n="layers">Layers</h2>
<span id="layer-count" class="badge">0</span> <span id="layer-count" class="badge">0</span>
</div> </div>
<p id="status" class="status">Waiting for connection.</p> <p id="status" class="status" data-i18n="waitingForConnection">Waiting for connection.</p>
<div id="layer-list" class="layer-list empty"> <div id="layer-list" class="layer-list empty" data-i18n="loadingPublishedLayers">
Loading published layers. Loading published layers.
</div> </div>
</section> </section>
<section class="panel">
<div class="panel-heading">
<h2 data-i18n="baseMap">Base map</h2>
</div>
<div class="background-selector">
<select
id="background-select"
name="background"
aria-label="Base map"
data-i18n-aria-label="baseMap"
>
<option value="soft" data-i18n="softMinimal">Soft minimal</option>
<option value="openstreetmap" data-i18n="openStreetMap">OpenStreetMap</option>
<option value="satellite" data-i18n="satelliteImagery">Satellite imagery</option>
</select>
</div>
</section>
</div>
</aside> </aside>
<main class="map-wrap"> <main class="map-wrap">
<div id="map" aria-label="Map view"></div> <div id="map" aria-label="Map view" data-i18n-aria-label="mapView"></div>
</main> </main>
<div id="feature-modal" class="feature-modal" hidden>
<div class="feature-modal-backdrop" data-feature-modal-close></div>
<section
class="feature-modal-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="feature-modal-title"
>
<button
id="feature-modal-close"
class="feature-modal-close"
type="button"
aria-label="Close feature information"
data-i18n-aria-label="closeFeatureInformation"
>
×
</button>
<div id="feature-modal-content"></div>
</section>
</div>
<div id="settings-modal" class="settings-modal" hidden>
<div class="settings-modal-backdrop" data-settings-modal-close></div>
<section
class="settings-modal-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="settings-modal-title"
>
<button
id="settings-modal-close"
class="settings-modal-close"
type="button"
aria-label="Close settings"
data-i18n-aria-label="closeSettings"
>
×
</button>
<h2 id="settings-modal-title" data-i18n="settings">Settings</h2>
<form id="settings-form" class="settings-form">
<label for="language-select">
<span data-i18n="applicationLanguage">Application language</span>
<select id="language-select" name="language"></select>
</label>
<button type="submit" data-i18n="applySettings">Apply</button>
</form>
</section>
</div>
</div> </div>
<script src="./vendor/ol.js"></script> <script src="./vendor/ol.js"></script>
<script src="./i18n.js"></script>
<script src="./app.js"></script> <script src="./app.js"></script>
</body> </body>
</html> </html>
+2 -1
View File
@@ -3,7 +3,8 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"start": "node --env-file-if-exists=.env server.js" "start": "node --env-file-if-exists=.env server.js",
"test": "node --test"
}, },
"dependencies": { "dependencies": {
"ol": "10.6.1" "ol": "10.6.1"
Executable
+69
View File
@@ -0,0 +1,69 @@
#!/bin/sh
set -eu
script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
cd "${script_dir}"
usage() {
echo "Usage: $0 [--build]" >&2
}
build_image=false
case "${1:-}" in
"")
;;
--build)
build_image=true
;;
*)
usage
exit 2
;;
esac
if [ "$#" -gt 1 ]; then
usage
exit 2
fi
if [ -f "docker-compose.yml" ]; then
compose_file="docker-compose.yml"
elif [ -f "compose.yaml" ]; then
compose_file="compose.yaml"
else
echo "No supported Compose file found." >&2
exit 1
fi
compose_backup="${compose_file}.bak"
if [ ! -f "${compose_backup}" ]; then
echo "Missing deployment Compose backup: ${compose_backup}" >&2
exit 1
fi
if ! cmp -s "${compose_backup}" "${compose_file}"; then
echo "${compose_file} differs from ${compose_backup}; refusing to republish." >&2
diff -u "${compose_backup}" "${compose_file}" >&2 || true
exit 1
fi
# Restore the deployment-specific Compose file even if a Git operation fails.
restore_compose() {
cp "${compose_backup}" "${compose_file}"
}
trap restore_compose EXIT
git restore -- "${compose_file}"
git fetch --all --prune
git pull --ff-only
restore_compose
trap - EXIT
docker compose down -v
if [ "${build_image}" = true ]; then
docker compose up -d --build
else
docker compose up -d
fi
+864 -21
View File
@@ -1,13 +1,14 @@
:root { :root {
color-scheme: light; color-scheme: light;
--bg: #f3efe3; --bg: #f3efe3;
--panel: rgba(253, 249, 240, 0.92); --panel: rgba(253, 249, 240, 0.75);
--panel-border: rgba(92, 73, 49, 0.18); --panel-border: rgba(92, 73, 49, 0.18);
--ink: #1f2a2e; --ink: #1f2a2e;
--muted: #5f6b6f; --muted: #5f6b6f;
--accent: #1f7a62; --accent: #1f7a62;
--accent-strong: #125441; --accent-strong: #125441;
--shadow: 0 24px 60px rgba(52, 43, 28, 0.12); --shadow: 0 24px 60px rgba(52, 43, 28, 0.12);
--map-tool-size: 42px;
} }
* { * {
@@ -29,11 +30,113 @@ body {
min-height: 100vh; min-height: 100vh;
} }
.app-shell { .message-region {
position: fixed;
z-index: 30;
top: 24px;
left: 50%;
display: grid; display: grid;
grid-template-columns: minmax(320px, 380px) 1fr; width: min(420px, calc(100vw - 32px));
gap: 10px;
transform: translateX(-50%);
pointer-events: none;
}
.message {
--message-background: #1f7a62;
--message-progress: #125441;
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
gap: 12px;
overflow: hidden;
padding: 14px 12px 16px 16px;
border: 1px solid rgba(255, 255, 255, 0.4);
border-radius: 16px;
background: var(--message-background);
color: #fff;
box-shadow: 0 16px 36px rgba(31, 42, 46, 0.26);
pointer-events: auto;
animation: message-enter 180ms ease-out;
}
.message--warning {
--message-background: #c76a13;
--message-progress: #914605;
}
.message--error {
--message-background: #b9382e;
--message-progress: #7f201a;
}
.message.is-leaving {
animation: message-leave 180ms ease-in forwards;
}
.message-text {
margin: 0;
font-weight: 650;
line-height: 1.35;
}
.message-dismiss {
width: 28px;
height: 28px;
min-width: 28px;
padding: 0;
border: 1px solid rgba(255, 255, 255, 0.45);
border-radius: 50%;
background: transparent;
color: inherit;
box-shadow: none;
font-size: 1.35rem;
line-height: 1;
}
.message-dismiss:hover,
.message-dismiss:focus-visible {
background: rgba(255, 255, 255, 0.18);
outline: 2px solid #fff;
outline-offset: 2px;
}
.message-progress {
position: absolute;
right: 0;
bottom: 0;
left: 0;
height: 4px;
background: var(--message-progress);
transform-origin: right;
animation: message-countdown var(--message-duration) linear forwards;
}
@keyframes message-enter {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes message-leave {
to { opacity: 0; transform: translateY(-8px); }
}
@keyframes message-countdown {
to { transform: scaleX(0); }
}
@media (prefers-reduced-motion: reduce) {
.message,
.message.is-leaving,
.message-progress {
animation-duration: 1ms;
}
}
.app-shell {
position: relative;
min-height: 100vh; min-height: 100vh;
gap: 24px;
padding: 24px; padding: 24px;
} }
@@ -43,9 +146,80 @@ body {
} }
.sidebar { .sidebar {
position: fixed;
z-index: 10;
top: 24px;
bottom: 24px;
left: 24px;
width: min(266px, calc(100vw - 48px));
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden;
padding: 8px;
transition: transform 240ms ease;
backdrop-filter: blur(3px);
}
.sidebar-content {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
gap: 16px; gap: 16px;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
.sidebar-toggle {
position: fixed;
z-index: 11;
display: grid;
width: 42px;
height: 42px;
padding: 0;
place-items: center;
border: 1px solid var(--panel-border);
border-radius: 14px;
background: var(--panel);
color: var(--ink);
font-size: 1.65rem;
font-weight: 500;
line-height: 1;
box-shadow: var(--shadow);
}
.sidebar-toggle:hover {
transform: translateY(-1px);
}
.sidebar-open {
top: 32px;
left: 32px;
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity 180ms ease;
}
.sidebar-close {
position: absolute;
top: 14px;
right: 14px;
}
.sidebar-close span[aria-hidden="true"] {
transform: translateY(-1px);
}
.app-shell.sidebar-is-collapsed .sidebar {
transform: translateX(calc(-100% - 24px));
}
.app-shell.sidebar-is-collapsed .sidebar-open {
opacity: 1;
visibility: visible;
pointer-events: auto;
} }
.brand, .brand,
@@ -58,7 +232,68 @@ body {
} }
.brand { .brand {
padding: 24px; padding: 24px 64px 24px 24px;
}
.brand-logo {
display: block;
width: min(100%, 178px);
height: auto;
}
.brand-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 8px;
/* Extend through the brand's reserved close-button clearance so the gear
and sidebar close controls share the same right-hand axis. */
margin-right: -60px;
}
.brand-subtitle {
color: var(--muted);
font-size: 1rem;
font-weight: 400;
letter-spacing: 0.04em;
}
.settings-open {
display: grid;
width: 42px;
height: 42px;
min-width: 42px;
padding: 0;
place-items: center;
border: 1px solid var(--panel-border);
border-radius: 50%;
background: rgba(255, 255, 255, 0.55);
color: var(--ink);
box-shadow: none;
}
.settings-open:hover,
.settings-open:focus-visible {
background: rgba(31, 122, 98, 0.1);
outline: 2px solid rgba(31, 122, 98, 0.25);
outline-offset: 2px;
}
.settings-open .material-symbols-outlined {
font-size: 1.3rem;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
} }
h1, h1,
@@ -93,6 +328,10 @@ form.panel {
width: 100%; width: 100%;
} }
.background-selector {
margin-top: 16px;
}
label { label {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -105,7 +344,8 @@ label span {
font-weight: 600; font-weight: 600;
} }
input { input,
select {
width: 100%; width: 100%;
padding: 12px 14px; padding: 12px 14px;
border-radius: 14px; border-radius: 14px;
@@ -115,7 +355,8 @@ input {
font: inherit; font: inherit;
} }
input:focus { input:focus,
select:focus {
outline: 2px solid rgba(31, 122, 98, 0.18); outline: 2px solid rgba(31, 122, 98, 0.18);
border-color: var(--accent); border-color: var(--accent);
} }
@@ -164,6 +405,10 @@ button:disabled {
color: var(--muted); color: var(--muted);
} }
.status:empty {
display: none;
}
.status.error { .status.error {
color: #9d2e21; color: #9d2e21;
} }
@@ -199,26 +444,17 @@ button:disabled {
margin-top: 3px; margin-top: 3px;
} }
.layer-meta {
display: grid;
gap: 3px;
}
.layer-title { .layer-title {
font-weight: 700; font-weight: 700;
} }
.layer-name {
color: var(--muted);
font-size: 0.9rem;
}
.map-wrap { .map-wrap {
position: relative; position: fixed;
z-index: 0;
inset: 24px;
overflow: hidden; overflow: hidden;
border-radius: 32px; border-radius: 32px;
box-shadow: var(--shadow); box-shadow: var(--shadow);
min-height: 70vh;
} }
#map { #map {
@@ -226,12 +462,619 @@ button:disabled {
inset: 0; inset: 0;
} }
.feature-modal[hidden] {
display: none;
}
.settings-modal[hidden] {
display: none;
}
.feature-modal,
.settings-modal {
position: fixed;
z-index: 20;
inset: 0;
display: grid;
padding: 24px;
place-items: center;
}
.feature-modal-backdrop,
.settings-modal-backdrop {
position: absolute;
inset: 0;
background: rgba(31, 42, 46, 0.35);
backdrop-filter: blur(1px);
}
.settings-modal-dialog {
position: relative;
width: min(100%, 380px);
padding: 28px;
border: 1px solid var(--panel-border);
border-radius: 24px;
background: var(--panel);
backdrop-filter: blur(16px);
box-shadow: var(--shadow);
}
.settings-modal-close {
position: absolute;
top: 12px;
right: 12px;
display: grid;
width: 36px;
height: 36px;
padding: 0;
place-items: center;
border: 1px solid var(--panel-border);
border-radius: 50%;
background: transparent;
color: var(--ink);
box-shadow: none;
font-size: 1.5rem;
line-height: 1;
}
.settings-modal-dialog h2 {
padding-right: 44px;
}
.settings-form {
display: grid;
gap: 20px;
margin-top: 24px;
}
.feature-modal-dialog {
position: relative;
width: min(100%, 360px);
padding: 28px;
border: 1px solid var(--panel-border);
border-radius: 24px;
background: var(--panel);
backdrop-filter: blur(3px);
box-shadow: var(--shadow);
cursor: grab;
touch-action: none;
}
.feature-modal-dialog.is-dragging {
cursor: grabbing;
user-select: none;
}
.feature-modal-close {
position: absolute;
top: 12px;
right: 12px;
display: grid;
width: 36px;
height: 36px;
padding: 0;
place-items: center;
border: 1px solid var(--panel-border);
border-radius: 50%;
background: transparent;
color: var(--ink);
box-shadow: none;
font-size: 1.5rem;
line-height: 1;
}
.feature-modal-kind {
margin-bottom: 6px;
color: var(--accent-strong);
font-size: 0.85rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.feature-details {
margin: 20px 0 0;
}
.feature-details div {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 16px;
padding: 14px 0;
border-top: 1px solid var(--panel-border);
}
.feature-details dt {
color: var(--muted);
}
.feature-details dd {
min-width: 0;
margin: 0;
font-size: 1.1rem;
font-weight: 700;
overflow-wrap: anywhere;
text-align: right;
}
.feature-inhabited {
margin: 14px 0 0;
color: #c62828;
font-weight: 700;
}
.feature-navigation,
.feature-association {
width: 100%;
margin-top: 16px;
}
.feature-associations {
margin-top: 20px;
border-top: 1px solid var(--panel-border);
padding-top: 14px;
}
.feature-associations h3 {
margin: 0;
font-size: 1rem;
}
.feature-associations ul {
display: grid;
gap: 10px;
margin: 12px 0 0;
padding: 0;
list-style: none;
}
.feature-association {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 4px 12px;
margin: 0;
padding: 12px 0;
border: 0;
border-radius: 0;
border-bottom: 1px solid var(--panel-border);
background: transparent;
color: var(--ink);
box-shadow: none;
text-align: left;
}
.feature-association:hover,
.feature-association:focus-visible {
background: rgba(31, 122, 98, 0.08);
}
.feature-association-label {
min-width: 0;
font-weight: 700;
overflow-wrap: anywhere;
}
.feature-association-area {
color: var(--muted);
font-size: 0.9rem;
white-space: nowrap;
}
.feature-association-inhabited {
grid-column: 1 / -1;
color: #c62828;
font-size: 0.9rem;
font-weight: 700;
}
.feature-modal-actions {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
.feature-modal-share {
display: grid;
width: 42px;
height: 42px;
padding: 0;
place-items: center;
border-radius: 50%;
}
.feature-modal-share .material-symbols-outlined {
font-size: 1.25rem;
line-height: 1;
}
.map-wrap .ol-zoom {
top: 16px;
right: 16px;
left: auto;
overflow: hidden;
background: var(--panel);
border: 1px solid var(--panel-border);
border-radius: 14px;
backdrop-filter: blur(16px);
box-shadow: var(--shadow);
}
.map-wrap .ol-zoom button,
.map-wrap .ol-visible-layers-extent button,
.map-wrap .ol-share button {
width: var(--map-tool-size);
height: var(--map-tool-size);
margin: 0;
background: linear-gradient(135deg, var(--accent) 0%, #2b9a79 100%);
color: #fffefb;
font-size: 1rem;
line-height: 1;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.24);
}
.map-wrap .ol-zoom button + button {
border-top: 1px solid rgba(255, 255, 255, 0.35);
}
.map-wrap .ol-zoom button:hover,
.map-wrap .ol-zoom button:focus,
.map-wrap .ol-visible-layers-extent button:hover,
.map-wrap .ol-visible-layers-extent button:focus,
.map-wrap .ol-share button:hover,
.map-wrap .ol-share button:focus {
background: linear-gradient(135deg, var(--accent-strong) 0%, var(--accent) 100%);
color: #fff;
outline: 2px solid rgba(31, 122, 98, 0.35);
outline-offset: -2px;
}
.map-wrap .ol-visible-layers-extent {
top: 108px;
right: 16px;
left: auto;
overflow: hidden;
background: var(--panel);
border: 1px solid var(--panel-border);
border-radius: 14px;
backdrop-filter: blur(16px);
box-shadow: var(--shadow);
}
.map-wrap .ol-visible-layers-extent button {
font-size: 1.3rem;
}
.map-wrap .ol-area-zoom {
top: 158px;
right: 16px;
left: auto;
overflow: hidden;
background: var(--panel);
border: 1px solid var(--panel-border);
border-radius: 14px;
backdrop-filter: blur(16px);
box-shadow: var(--shadow);
}
.map-wrap .ol-area-zoom button {
display: grid;
width: var(--map-tool-size);
height: var(--map-tool-size);
margin: 0;
padding: 0;
place-items: center;
background: linear-gradient(135deg, var(--accent) 0%, #2b9a79 100%);
color: #fffefb;
font-size: 1.3rem;
line-height: 1;
}
.map-wrap .ol-area-zoom button:hover,
.map-wrap .ol-area-zoom button:focus,
.map-wrap .ol-area-zoom button[aria-pressed="true"] {
background: linear-gradient(135deg, var(--accent-strong) 0%, var(--accent) 100%);
color: #fff;
outline: 2px solid rgba(31, 122, 98, 0.35);
outline-offset: -2px;
}
.map-wrap .ol-share {
top: 208px;
right: 16px;
left: auto;
overflow: hidden;
background: var(--panel);
border: 1px solid var(--panel-border);
border-radius: 14px;
backdrop-filter: blur(16px);
box-shadow: var(--shadow);
}
/* When available, keep Share after the device compass in the tool stack. */
.map-wrap .ol-device-compass ~ .ol-share {
top: 258px;
}
.map-wrap .ol-share button {
display: grid;
place-items: center;
}
.map-wrap .ol-share .material-symbols-outlined {
font-size: 1.25rem;
line-height: 1;
}
.area-zoom-icon {
display: block;
width: 1.2em;
height: 1.2em;
filter: brightness(0) invert(1);
}
.map-wrap .ol-viewport.area-zoom-is-active {
cursor: crosshair;
}
.map-wrap .ol-area-zoom-box {
border-color: var(--accent);
background: rgba(31, 122, 98, 0.16);
}
.map-wrap .ol-device-compass {
top: 208px;
right: 16px;
left: auto;
overflow: hidden;
background: var(--panel);
border: 1px solid var(--panel-border);
border-radius: 14px;
backdrop-filter: blur(16px);
box-shadow: var(--shadow);
}
.map-wrap .ol-device-compass button {
display: grid;
width: var(--map-tool-size);
height: var(--map-tool-size);
margin: 0;
padding: 0;
place-items: center;
background: linear-gradient(135deg, var(--accent) 0%, #2b9a79 100%);
color: #fffefb;
font-size: 1.3rem;
line-height: 1;
}
.map-wrap .ol-device-compass button:hover,
.map-wrap .ol-device-compass button:focus {
background: linear-gradient(135deg, var(--accent-strong) 0%, var(--accent) 100%);
color: #fff;
outline: 2px solid rgba(31, 122, 98, 0.35);
outline-offset: -2px;
}
/* Compass orientation stays visibly engaged after the pointer leaves the
control. The inset high-contrast ring and explicit label do not rely on
the green active colour alone. */
.map-wrap .ol-device-compass button[aria-pressed="true"] {
position: relative;
border: 3px solid #fffefb;
background: linear-gradient(135deg, var(--accent-strong) 0%, var(--accent) 100%);
color: #fff;
outline: 2px solid #1f2a2e;
outline-offset: -5px;
}
.map-wrap .ol-device-compass button[aria-pressed="true"]::after {
position: absolute;
top: 2px;
right: 2px;
padding: 1px 2px;
border-radius: 3px;
background: #fffefb;
color: #1f2a2e;
content: "ON";
font-size: 0.45rem;
font-weight: 800;
letter-spacing: 0.02em;
line-height: 1;
}
.device-compass-icon {
display: block;
width: 1.2em;
height: 1.2em;
}
/* OpenLayers hides this control while the view is north-up. */
.map-wrap .ol-rotate {
top: auto;
right: 16px;
bottom: 16px;
left: auto;
padding: 0;
background: transparent;
}
.map-wrap .ol-rotate button {
display: grid;
width: 42px;
height: 42px;
margin: 0;
padding: 0;
place-items: center;
border-radius: 50%;
background: rgba(255, 255, 255, 0.9);
line-height: 1;
box-shadow: 0 10px 24px rgba(52, 43, 28, 0.2);
}
.north-pointer-icon {
display: block;
width: 26px;
height: 26px;
transform-origin: center;
}
.map-wrap .ol-rotate button:hover {
background: #fff;
transform: translateY(-1px);
}
@media (max-width: 920px) { @media (max-width: 920px) {
.app-shell { .app-shell {
grid-template-columns: 1fr; padding: 16px;
} }
.map-wrap { .map-wrap {
min-height: 62vh; inset: 16px;
border-radius: 24px;
}
.map-wrap .ol-zoom {
top: 12px;
right: 12px;
}
.map-wrap .ol-visible-layers-extent {
top: 104px;
right: 12px;
}
.map-wrap .ol-area-zoom {
top: 154px;
right: 12px;
}
.map-wrap .ol-share {
top: 204px;
right: 12px;
}
.map-wrap .ol-device-compass ~ .ol-share {
top: 254px;
}
.map-wrap .ol-device-compass {
top: 204px;
right: 12px;
}
.map-wrap .ol-rotate {
right: 12px;
bottom: 12px;
}
.sidebar {
top: 16px;
bottom: 16px;
left: 16px;
width: min(266px, calc(100vw - 32px));
}
.sidebar-content {
gap: 10px;
padding-right: 2px;
}
.sidebar-close {
top: 12px;
right: 12px;
}
.brand {
padding: 16px 58px 14px 16px;
border-radius: 18px;
}
.brand-logo {
width: min(100%, 136px);
}
.brand-subtitle {
margin-top: 4px;
font-size: 0.85rem;
}
.panel {
padding: 14px;
border-radius: 18px;
}
.panel-heading {
gap: 8px;
}
.parcel-search-form,
.background-selector,
.layer-list {
margin-top: 10px;
}
.parcel-search-form {
gap: 8px;
}
.status {
margin-top: 8px;
font-size: 0.9rem;
}
input,
select {
padding: 10px 12px;
}
button {
padding: 11px 14px;
}
.layer-list {
gap: 8px;
}
.layer-item {
gap: 10px;
padding: 10px;
border-radius: 14px;
}
.sidebar-open {
top: 24px;
left: 24px;
}
.app-shell.sidebar-is-collapsed .sidebar {
transform: translateX(calc(-100% - 16px));
}
}
@media (max-width: 920px) and (max-height: 700px) {
.sidebar-content {
gap: 8px;
}
.brand {
padding-top: 12px;
padding-bottom: 10px;
}
.brand-logo {
width: min(100%, 112px);
}
.brand-subtitle {
display: none;
}
.panel {
padding: 12px;
}
.panel h2 {
font-size: 1.1rem;
} }
} }
+24
View File
@@ -0,0 +1,24 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const root = path.resolve(__dirname, "..");
test("the Docker image includes every application script referenced by the page", () => {
const html = fs.readFileSync(path.join(root, "index.html"), "utf8");
const dockerfile = fs.readFileSync(path.join(root, "Dockerfile"), "utf8");
const applicationScripts = [...html.matchAll(/<script src="\.\/([^"]+\.js)"/g)]
.map((match) => match[1])
.filter((scriptPath) => !scriptPath.startsWith("vendor/"));
assert.ok(applicationScripts.length > 0);
applicationScripts.forEach((scriptPath) => {
assert.ok(fs.existsSync(path.join(root, scriptPath)), `${scriptPath} must exist`);
assert.match(
dockerfile,
new RegExp(`^COPY ${scriptPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} \\.\\/$`, "m"),
`${scriptPath} must be copied by the Dockerfile`,
);
});
});
+85
View File
@@ -0,0 +1,85 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const {
DEFAULT_LANGUAGE,
TRANSLATIONS,
createI18n,
detectLanguage,
normalizeLanguage,
} = require("../i18n.js");
function createStorage(initialValue) {
let value = initialValue;
return {
getItem() {
return value;
},
setItem(key, nextValue) {
value = nextValue;
},
removeItem() {
value = undefined;
},
};
}
test("normalizes exact and base language tags", () => {
assert.equal(normalizeLanguage("pt-PT"), "pt-PT");
assert.equal(normalizeLanguage("pt-BR"), "pt-PT");
assert.equal(normalizeLanguage("en-US"), "en");
assert.equal(normalizeLanguage("fr"), null);
});
test("prefers a stored language over browser preferences", () => {
const storage = createStorage("en");
assert.equal(detectLanguage({ storage, languages: ["pt-PT"] }), "en");
});
test("uses the first supported browser language and otherwise falls back", () => {
assert.equal(detectLanguage({ storage: createStorage(), languages: ["fr", "pt-BR"] }), "pt-PT");
assert.equal(detectLanguage({ storage: createStorage(), languages: ["es-MX"] }), "es");
assert.equal(detectLanguage({ storage: createStorage(), languages: ["fr"] }), DEFAULT_LANGUAGE);
});
test("persists language changes and interpolates translated values", () => {
const storage = createStorage();
const i18n = createI18n({ storage, languages: ["en"] });
assert.equal(i18n.setLanguage("pt"), "pt-PT");
assert.equal(storage.getItem(), "pt-PT");
assert.equal(
i18n.t("noParcelFound", { parcelNumber: "57" }),
"Não foi encontrada a parcela com o número “57”.",
);
});
test("browser-default mode removes the preference and follows the browser language", () => {
const storage = createStorage("en");
const i18n = createI18n({ storage, languages: ["pt-PT"] });
assert.equal(i18n.setLanguage(null), "pt-PT");
assert.equal(i18n.preference, null);
assert.equal(storage.getItem(), undefined);
});
test("uses English for missing translations and the key for unknown messages", () => {
const storage = createStorage("pt-PT");
const originalTranslation = TRANSLATIONS["pt-PT"].zoomIn;
delete TRANSLATIONS["pt-PT"].zoomIn;
try {
const i18n = createI18n({ storage, languages: [] });
assert.equal(i18n.t("zoomIn"), TRANSLATIONS.en.zoomIn);
assert.equal(i18n.t("notARealMessage"), "notARealMessage");
} finally {
TRANSLATIONS["pt-PT"].zoomIn = originalTranslation;
}
});
test("all shipped languages contain the default translation keys", () => {
const defaultKeys = Object.keys(TRANSLATIONS[DEFAULT_LANGUAGE]).sort();
Object.entries(TRANSLATIONS).forEach(([language, translations]) => {
assert.deepEqual(Object.keys(translations).sort(), defaultKeys, language);
});
});