diff --git a/Furious/Data/maplibre/EndpointMap.js b/Furious/Data/maplibre/EndpointMap.js index ef12f0d..21be8ab 100644 --- a/Furious/Data/maplibre/EndpointMap.js +++ b/Furious/Data/maplibre/EndpointMap.js @@ -6,11 +6,378 @@ let marker = null; let state = null; let appliedRevision = -1; - let activeStyle = ''; + let activeDarkMode = null; let initialReadyReported = false; const loadingOverlay = document.getElementById('endpoint-loading-overlay'); const loadingText = document.getElementById('endpoint-loading-text'); + const nameField = [ + 'case', + ['all', ['has', 'name:latin'], ['has', 'name:nonlatin']], + ['concat', ['get', 'name:latin'], '\n', ['get', 'name:nonlatin']], + [ + 'coalesce', + ['get', 'name_en'], + ['get', 'name'], + ['get', 'name:latin'], + '', + ], + ]; + + const createStyle = (darkMode) => { + const colors = darkMode + ? { + background: '#0d1117', + land: '#161c25', + landcover: '#18271f', + landuse: '#1b222c', + water: '#19334a', + boundary: '#536174', + road: '#3e4a59', + roadMajor: '#657489', + building: '#222c38', + label: '#eef4fb', + labelMuted: '#b9c5d3', + halo: '#111720', + waterLabel: '#8fc4e8', + poi: '#c6d0dc', + } + : { + background: '#edf1f5', + land: '#f7f8fa', + landcover: '#e2ebdf', + landuse: '#eceff3', + water: '#bad9ec', + boundary: '#a7b0bd', + road: '#ffffff', + roadMajor: '#d4aa72', + building: '#d9dce1', + label: '#202832', + labelMuted: '#536170', + halo: '#ffffff', + waterLabel: '#446f91', + poi: '#4e5b68', + }; + + return { + version: 8, + glyphs: 'https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf', + sources: { + openmaptiles: { + type: 'vector', + url: 'https://tiles.openfreemap.org/planet', + attribution: 'OpenFreeMap ' + + 'OpenStreetMap', + }, + }, + layers: [ + { + id: 'background', + type: 'background', + paint: {'background-color': colors.background}, + }, + { + id: 'land', + type: 'fill', + source: 'openmaptiles', + 'source-layer': 'landcover', + paint: {'fill-color': colors.land}, + }, + { + id: 'landcover', + type: 'fill', + source: 'openmaptiles', + 'source-layer': 'landcover', + paint: { + 'fill-color': colors.landcover, + 'fill-opacity': 0.55, + }, + }, + { + id: 'landuse', + type: 'fill', + source: 'openmaptiles', + 'source-layer': 'landuse', + paint: { + 'fill-color': colors.landuse, + 'fill-opacity': 0.5, + }, + }, + { + id: 'water', + type: 'fill', + source: 'openmaptiles', + 'source-layer': 'water', + paint: {'fill-color': colors.water}, + }, + { + id: 'boundary', + type: 'line', + source: 'openmaptiles', + 'source-layer': 'boundary', + paint: { + 'line-color': colors.boundary, + 'line-width': 0.8, + 'line-opacity': 0.7, + }, + }, + { + id: 'transportation', + type: 'line', + source: 'openmaptiles', + 'source-layer': 'transportation', + minzoom: 5, + paint: { + 'line-color': colors.road, + 'line-width': 0.8, + 'line-opacity': 0.65, + }, + }, + { + id: 'transportation-major', + type: 'line', + source: 'openmaptiles', + 'source-layer': 'transportation', + minzoom: 6, + filter: [ + 'match', + ['get', 'class'], + ['motorway', 'trunk', 'primary', 'secondary', 'tertiary'], + true, + false, + ], + paint: { + 'line-color': colors.roadMajor, + 'line-width': [ + 'interpolate', + ['linear'], + ['zoom'], + 6, + 0.8, + 14, + 2.4, + ], + 'line-opacity': 0.85, + }, + }, + { + id: 'building', + type: 'fill', + source: 'openmaptiles', + 'source-layer': 'building', + minzoom: 13, + paint: { + 'fill-color': colors.building, + 'fill-opacity': 0.72, + }, + }, + { + id: 'water-name', + type: 'symbol', + source: 'openmaptiles', + 'source-layer': 'water_name', + minzoom: 3, + layout: { + 'symbol-placement': 'point', + 'text-field': nameField, + 'text-font': ['Noto Sans Italic'], + 'text-size': [ + 'interpolate', + ['linear'], + ['zoom'], + 3, + 10, + 12, + 14, + ], + 'text-max-width': 8, + }, + paint: { + 'text-color': colors.waterLabel, + 'text-halo-color': colors.halo, + 'text-halo-width': 1, + }, + }, + { + id: 'road-name-major', + type: 'symbol', + source: 'openmaptiles', + 'source-layer': 'transportation_name', + minzoom: 11, + filter: [ + 'match', + ['get', 'class'], + ['motorway', 'trunk', 'primary', 'secondary', 'tertiary'], + true, + false, + ], + layout: { + 'symbol-placement': 'line', + 'symbol-spacing': 300, + 'text-field': nameField, + 'text-font': ['Noto Sans Regular'], + 'text-size': [ + 'interpolate', + ['linear'], + ['zoom'], + 11, + 10, + 16, + 13, + ], + }, + paint: { + 'text-color': colors.labelMuted, + 'text-halo-color': colors.halo, + 'text-halo-width': 1, + }, + }, + { + id: 'road-name-minor', + type: 'symbol', + source: 'openmaptiles', + 'source-layer': 'transportation_name', + minzoom: 14, + filter: [ + 'match', + ['get', 'class'], + ['minor', 'service', 'track', 'path'], + true, + false, + ], + layout: { + 'symbol-placement': 'line', + 'symbol-spacing': 260, + 'text-field': nameField, + 'text-font': ['Noto Sans Regular'], + 'text-size': 11, + }, + paint: { + 'text-color': colors.labelMuted, + 'text-halo-color': colors.halo, + 'text-halo-width': 1, + }, + }, + { + id: 'place-country', + type: 'symbol', + source: 'openmaptiles', + 'source-layer': 'place', + minzoom: 2, + maxzoom: 9, + filter: ['==', ['get', 'class'], 'country'], + layout: { + 'text-field': nameField, + 'text-font': ['Noto Sans Bold'], + 'text-size': [ + 'interpolate', + ['linear'], + ['zoom'], + 2, + 10, + 7, + 16, + ], + 'text-max-width': 7, + }, + paint: { + 'text-color': colors.label, + 'text-halo-color': colors.halo, + 'text-halo-width': 1.2, + }, + }, + { + id: 'place-state', + type: 'symbol', + source: 'openmaptiles', + 'source-layer': 'place', + minzoom: 4, + maxzoom: 10, + filter: ['==', ['get', 'class'], 'state'], + layout: { + 'text-field': nameField, + 'text-font': ['Noto Sans Italic'], + 'text-size': 11, + 'text-letter-spacing': 0.08, + 'text-max-width': 8, + }, + paint: { + 'text-color': colors.labelMuted, + 'text-halo-color': colors.halo, + 'text-halo-width': 1, + }, + }, + { + id: 'place-city', + type: 'symbol', + source: 'openmaptiles', + 'source-layer': 'place', + minzoom: 3, + filter: [ + 'match', + ['get', 'class'], + ['city', 'town', 'village'], + true, + false, + ], + layout: { + 'text-field': nameField, + 'text-font': ['Noto Sans Regular'], + 'text-size': [ + 'interpolate', + ['linear'], + ['zoom'], + 4, + 11, + 12, + 15, + ], + 'text-max-width': 8, + 'text-variable-anchor': ['top', 'bottom', 'left', 'right'], + 'text-radial-offset': 0.4, + }, + paint: { + 'text-color': colors.label, + 'text-halo-color': colors.halo, + 'text-halo-width': 1.2, + }, + }, + { + id: 'poi-name', + type: 'symbol', + source: 'openmaptiles', + 'source-layer': 'poi', + minzoom: 15, + layout: { + 'text-field': nameField, + 'text-font': ['Noto Sans Regular'], + 'text-size': 10, + 'text-max-width': 9, + }, + paint: { + 'text-color': colors.poi, + 'text-halo-color': colors.halo, + 'text-halo-width': 1, + }, + }, + ], + }; + }; + + const coordinate = () => { + if (!state || !state.markerVisible) { + return null; + } + + const longitude = Number(state.markerLongitude); + const latitude = Number(state.markerLatitude); + if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) { + return null; + } + + return [longitude, latitude]; + }; + const applyLoadingState = () => { const loading = Boolean(state && state.loading); loadingOverlay.dataset.visible = String(loading); @@ -37,7 +404,8 @@ }; const ensureMarker = () => { - if (!map || !state || !state.markerVisible) { + const markerCoordinate = coordinate(); + if (!map || !markerCoordinate) { if (marker) { marker.remove(); marker = null; @@ -52,7 +420,7 @@ } marker - .setLngLat([state.markerLongitude, state.markerLatitude]) + .setLngLat(markerCoordinate) .addTo(map); }; @@ -84,18 +452,18 @@ return; } - const nextStyle = state.darkMode - ? state.darkStyleUrl - : state.lightStyleUrl; - if (nextStyle !== activeStyle) { - activeStyle = nextStyle; + const nextDarkMode = Boolean(state.darkMode); + if (nextDarkMode !== activeDarkMode) { + activeDarkMode = nextDarkMode; + const nextStyle = createStyle(nextDarkMode); map.setStyle(nextStyle); } - if (state.viewRevision !== appliedRevision) { + const nextCoordinate = coordinate(); + if (nextCoordinate && state.viewRevision !== appliedRevision) { appliedRevision = state.viewRevision; map.jumpTo({ - center: [state.markerLongitude, state.markerLatitude], + center: nextCoordinate, zoom: state.defaultGeographicZoom, }); } @@ -105,20 +473,24 @@ }; const createMap = () => { - if (map || !state || !state.markerVisible) { + const initialCoordinate = coordinate(); + if (map || !initialCoordinate) { return; } - activeStyle = state.darkMode - ? state.darkStyleUrl - : state.lightStyleUrl; + activeDarkMode = Boolean(state.darkMode); map = new maplibregl.Map({ container: 'map', - style: activeStyle, - center: [state.markerLongitude, state.markerLatitude], + style: createStyle(activeDarkMode), + center: initialCoordinate, zoom: state.defaultGeographicZoom, minZoom: 2, maxZoom: 18, + maxTileCacheSize: 64, + canvasContextAttributes: { + antialias: false, + powerPreference: 'low-power', + }, attributionControl: true, cooperativeGestures: false, fadeDuration: 0, diff --git a/Furious/Widget/EndpointInfoWidget.py b/Furious/Widget/EndpointInfoWidget.py index 301785f..9a07783 100644 --- a/Furious/Widget/EndpointInfoWidget.py +++ b/Furious/Widget/EndpointInfoWidget.py @@ -35,7 +35,6 @@ from PySide6.QtWebEngineCore import ( from PySide6.QtWebEngineWidgets import QWebEngineView from PySide6.QtWidgets import * -import enum import json import logging @@ -82,20 +81,7 @@ class _EndpointMapBridge(QtCore.QObject): class _EndpointMapWidget(QWidget): """Host one lazily loaded MapLibre vector map for the page lifetime.""" - class Style(enum.StrEnum): - """OpenFreeMap styles available to the embedded endpoint map.""" - - Bright, Liberty, Positron, Dark, Fiord = ( - 'https://tiles.openfreemap.org/styles/bright', - 'https://tiles.openfreemap.org/styles/liberty', - 'https://tiles.openfreemap.org/styles/positron', - 'https://tiles.openfreemap.org/styles/dark', - 'https://tiles.openfreemap.org/styles/fiord', - ) - HtmlPath = DATA_DIR / 'maplibre' / 'EndpointMap.html' - LightStyle = Style.Liberty - DarkStyle = Style.Fiord TrustedAttributionHosts = frozenset( { 'openfreemap.org', @@ -123,6 +109,7 @@ class _EndpointMapWidget(QWidget): self._documentLoaded = False self._mapReady = False self._mapError = '' + self._rendererFailureHandled = False self._viewRevision = 0 self._lastWebState = {} self._retainMapDuringRefresh = False @@ -350,10 +337,15 @@ class _EndpointMapWidget(QWidget): """Publish local-document failures and send the first map state.""" self._documentLoaded = bool(successful) - if not successful: + if successful: + self._rendererFailureHandled = False + else: self._mapError = self._unavailableText or 'Endpoint map unavailable' - logger.error('failed to load the local endpoint map document') + # A renderer crash also completes the outstanding load with False. + # The termination handler already records that more precise cause. + if not self._rendererFailureHandled: + logger.error('failed to load the local endpoint map document') self._syncWebState() self._updateOverlay() @@ -378,6 +370,11 @@ class _EndpointMapWidget(QWidget): @QtCore.Slot(QWebEnginePage.RenderProcessTerminationStatus, int) def _renderProcessTerminated(self, status, exitCode): """Convert an unexpected Chromium exit into the normal map fallback.""" + if self._rendererFailureHandled: + return + + self._rendererFailureHandled = True + self._documentLoaded = False self._mapReady = False self._mapError = self._unavailableText or 'Endpoint map unavailable' @@ -416,8 +413,6 @@ class _EndpointMapWidget(QWidget): 'defaultGeographicZoom': self.DefaultGeographicZoom, 'viewRevision': self._viewRevision, 'darkMode': self._theme == AppStyleSheet.Dark, - 'lightStyleUrl': self.LightStyle.value, - 'darkStyleUrl': self.DarkStyle.value, 'accentColor': accentColor, 'loading': self._loading or ( diff --git a/tests/test_endpoint_info.py b/tests/test_endpoint_info.py index 50a19ab..e1471f5 100644 --- a/tests/test_endpoint_info.py +++ b/tests/test_endpoint_info.py @@ -805,26 +805,19 @@ class EndpointInfoServiceTest(unittest.TestCase): self.assertIn('.maplibregl-canvas:focus', html) self.assertIn('outline: none', html) self.assertNotIn('color-mix(', html) - self.assertEqual( - {style.name: style.value for style in widget.mapWidget.Style}, - { - 'Bright': 'https://tiles.openfreemap.org/styles/bright', - 'Liberty': 'https://tiles.openfreemap.org/styles/liberty', - 'Positron': 'https://tiles.openfreemap.org/styles/positron', - 'Dark': 'https://tiles.openfreemap.org/styles/dark', - 'Fiord': 'https://tiles.openfreemap.org/styles/fiord', - }, - ) - self.assertEqual( - widget.mapWidget._lastWebState['lightStyleUrl'], - widget.mapWidget.LightStyle.value, - ) - self.assertIs(widget.mapWidget.LightStyle, widget.mapWidget.Style.Liberty) - self.assertEqual( - widget.mapWidget._lastWebState['darkStyleUrl'], - widget.mapWidget.DarkStyle.value, - ) - self.assertIs(widget.mapWidget.DarkStyle, widget.mapWidget.Style.Fiord) + self.assertIn('const createStyle = (darkMode)', mapScript) + self.assertIn("url: 'https://tiles.openfreemap.org/planet'", mapScript) + self.assertIn("glyphs: 'https://tiles.openfreemap.org/fonts/", mapScript) + self.assertIn("'source-layer': 'water'", mapScript) + self.assertIn("'source-layer': 'building'", mapScript) + self.assertIn("'source-layer': 'transportation_name'", mapScript) + self.assertIn("'source-layer': 'place'", mapScript) + self.assertIn("'source-layer': 'poi'", mapScript) + self.assertIn("'text-field': nameField", mapScript) + self.assertIn('Number.isFinite(longitude)', mapScript) + self.assertNotIn('lightStyleUrl', widget.mapWidget._lastWebState) + self.assertNotIn('darkStyleUrl', widget.mapWidget._lastWebState) + self.assertNotIn('/styles/fiord', mapScript) self.assertNotIn('tile.openstreetmap.org', html) self.assertNotIn('opacity: 0.60', html) self.assertNotIn('opacity: 0.60', mapScript) @@ -948,10 +941,6 @@ class EndpointInfoServiceTest(unittest.TestCase): self.assertEqual(widget.mapWidget._theme, AppStyleSheet.Light) self.assertFalse(widget.mapWidget._lastWebState['darkMode']) - self.assertEqual( - widget.mapWidget._lastWebState['lightStyleUrl'], - widget.mapWidget.LightStyle.value, - ) self.assertTrue(scripts) widget.close()