Compare commits
29
Commits
c0686d0c5a
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16383c2a3f | ||
|
|
d2567c59ce | ||
|
|
a478f8972f | ||
|
|
8d4f003b43 | ||
|
|
ae95447dc8 | ||
|
|
bf44014dc1 | ||
|
|
2510bc583d | ||
|
|
fadd2af414 | ||
|
|
46efce287d | ||
|
|
f8964a8183 | ||
|
|
9449bf9b93 | ||
|
|
623838c861 | ||
|
|
1d4d09ff5e | ||
|
|
a0566b1225 | ||
|
|
3a7bfbf0c2 | ||
|
|
f4e55349ca | ||
|
|
ff8d55f1c0 | ||
|
|
93ac14941e | ||
|
|
1e25cfc575 | ||
|
|
c60840e4c1 | ||
|
|
ec922c22ff | ||
|
|
f76b553fc4 | ||
|
|
5851517a6b | ||
|
|
f706128e9c | ||
|
|
41ded45b09 | ||
|
|
4ddb7fc2d8 | ||
|
|
0e09c67460 | ||
|
|
5d19c5f085 | ||
|
|
440ee6cc9a |
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 |
@@ -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 |
@@ -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 |
+4
-2
@@ -7,9 +7,11 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
PORT: ${APP_PORT:-4173}
|
PORT: ${APP_PORT:-4173}
|
||||||
WMS_ENDPOINT: ${WMS_ENDPOINT:-https://xpro-viz.jfig.net/geoserver/xpro/wms}
|
WMS_ENDPOINT: ${DOCKER_WMS_ENDPOINT:-${WMS_ENDPOINT:-https://xpro-viz.jfig.net/geoserver/xpro/wms}}
|
||||||
ALLOWED_WMS_ENDPOINTS: ${ALLOWED_WMS_ENDPOINTS:-https://xpro-viz.jfig.net/geoserver/xpro/wms}
|
ALLOWED_WMS_ENDPOINTS: ${DOCKER_ALLOWED_WMS_ENDPOINTS:-${ALLOWED_WMS_ENDPOINTS:-https://xpro-viz.jfig.net/geoserver/xpro/wms}}
|
||||||
GEOSERVER_USERNAME: ${GEOSERVER_USERNAME:-admin}
|
GEOSERVER_USERNAME: ${GEOSERVER_USERNAME:-admin}
|
||||||
GEOSERVER_PASSWORD: ${GEOSERVER_PASSWORD}
|
GEOSERVER_PASSWORD: ${GEOSERVER_PASSWORD}
|
||||||
ports:
|
ports:
|
||||||
- "${APP_PORT:-4173}:${APP_PORT:-4173}"
|
- "${APP_PORT:-4173}:${APP_PORT:-4173}"
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
|||||||
@@ -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);
|
||||||
+145
-7
@@ -3,35 +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
|
||||||
|
id="message-region"
|
||||||
|
class="message-region"
|
||||||
|
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">
|
<div class="brand">
|
||||||
<h1>XPro Map Visualizer</h1>
|
<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>
|
</div>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<div class="panel-heading">
|
<div class="panel-heading">
|
||||||
<h2>Layers</h2>
|
<h2 data-i18n="find">Find</h2>
|
||||||
|
</div>
|
||||||
|
<form id="parcel-search-form" class="parcel-search-form">
|
||||||
|
<input
|
||||||
|
id="parcel-search-input"
|
||||||
|
name="parcel"
|
||||||
|
type="search"
|
||||||
|
inputmode="text"
|
||||||
|
placeholder="e.g. 57 or 2669.1"
|
||||||
|
data-i18n-placeholder="parcelExample"
|
||||||
|
autocomplete="off"
|
||||||
|
aria-label="Parcel number"
|
||||||
|
data-i18n-aria-label="parcelNumber"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button type="submit" data-i18n="findOnMap">Find on map</button>
|
||||||
|
</form>
|
||||||
|
<p id="parcel-search-status" class="status" aria-live="polite"></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<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
@@ -3,7 +3,8 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node 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
@@ -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
|
||||||
+874
-21
@@ -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,
|
||||||
@@ -83,6 +318,20 @@ form.panel {
|
|||||||
gap: 14px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.parcel-search-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parcel-search-form button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.background-selector {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
label {
|
label {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -95,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;
|
||||||
@@ -105,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);
|
||||||
}
|
}
|
||||||
@@ -154,6 +405,10 @@ button:disabled {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.status.error {
|
.status.error {
|
||||||
color: #9d2e21;
|
color: #9d2e21;
|
||||||
}
|
}
|
||||||
@@ -189,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 {
|
||||||
@@ -216,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user