diff --git a/site/src/App.jsx b/site/src/App.jsx index c5dd1db4..14b770f3 100644 --- a/site/src/App.jsx +++ b/site/src/App.jsx @@ -7,16 +7,19 @@ import { useState, useEffect, useRef } from "react"; import Tour from "./Tour"; import StartupBox from "./StartupBox"; import { ThemeProvider } from "@mui/material"; +import { useNavigatorState } from "./navigator/Navigator"; function App() { const [modeName, setModeName] = useState(getTheme()); const [run, setRun] = useState(false); const [tour, setTour] = useState(!(localStorage.getItem("tour") === "true")); const [open, setOpen] = useState(tour); - const [navigatorOpen, setNavigatorOpen] = useState(!open); - const [mapEntity, setMapEntity] = useState({}); + const [navigatorOpen, setNavigatorOpen] = useNavigatorState(open); + + const [features, setFeatures] = useState([]); const [zoom, setZoom] = useState(0); const themeRef = useRef(null); + const [activeFeature, setActiveFeature] = useState(null); const startTour = () => { setOpen(false); @@ -46,7 +49,7 @@ function App() { @@ -59,12 +62,14 @@ function App() { /> diff --git a/site/src/CustomControls.css b/site/src/CustomControls.css index 2ff2e163..56152ddd 100644 --- a/site/src/CustomControls.css +++ b/site/src/CustomControls.css @@ -6,12 +6,17 @@ color: black; margin: 4px; outline: none; + pointer-events: none; .title { padding: 2px 0; } } +.custom-controls * { + pointer-events: auto; +} + div.bug-nub { float: right; margin-top: 146px; diff --git a/site/src/FeatureSelector.css b/site/src/FeatureSelector.css new file mode 100644 index 00000000..a53cd410 --- /dev/null +++ b/site/src/FeatureSelector.css @@ -0,0 +1,66 @@ +.popup-content { + padding: 2px; + max-height: 300px; + overflow-y: auto; +} + +.theme-dark .maplibregl-popup-content { + background-color: #121212; + color: #fff; +} + +.theme-dark .maplibregl-popup-close-button { + color: #fff; +} + +.feature-selector-title { + font-size: 16px; + font-weight: bold; + margin-bottom: 10px; +} + +.theme-light .feature-item.active { + background-color: #eee; +} + +.theme-dark .feature-item.active { + background-color: #333; +} + +.feature-item { + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; + padding: 2px 0; +} + +.theme-light .feature-item { + border-bottom: 1px solid #eee; +} + +.theme-dark .feature-item { + border-bottom: 1px solid #333; +} + +.theme-light .feature-item:hover { + background-color: #f5f5f5; +} + +.theme-dark .feature-item:hover { + background-color: #2a2a2a; +} + +.feature-item:last-child { + border-bottom: none; +} + +.theme-light .feature-item span { + font-size: 14px; + color: #333; +} + +.theme-dark .feature-item span { + font-size: 14px; + color: #eee; +} diff --git a/site/src/FeatureSelector.jsx b/site/src/FeatureSelector.jsx new file mode 100644 index 00000000..b7a0a1a8 --- /dev/null +++ b/site/src/FeatureSelector.jsx @@ -0,0 +1,66 @@ +import { Popup } from "react-map-gl/maplibre"; +import PropTypes from "prop-types"; +import ThemeIcon from "./inspector_panel/ThemeIcon"; +import FeatureTitle from "./FeatureTitle"; +import "./FeatureSelector.css"; + +export default function FeaturePopup({ + coordinates, + features, + onClose, + activeFeature, + setActiveFeature, +}) { + if (!coordinates || features.length < 2) return null; + return ( + +
+
Select a feature
+ {features.map((feature, index) => { + const entity = { + theme: feature.source, + type: feature.sourceLayer, + ...feature.properties, + }; + return ( +
{ + if (feature === activeFeature) { + setActiveFeature(null); + } else { + setActiveFeature(feature); + } + }} + > + + + + +
+ ); + })} +
+
+ ); +} + +FeaturePopup.propTypes = { + coordinates: PropTypes.shape({ + longitude: PropTypes.number, + latitude: PropTypes.number, + }), + features: PropTypes.array.isRequired, + onClose: PropTypes.func.isRequired, + activeFeature: PropTypes.object, + setActiveFeature: PropTypes.func.isRequired, +}; diff --git a/site/src/FeatureTitle.jsx b/site/src/FeatureTitle.jsx new file mode 100644 index 00000000..89fb5d06 --- /dev/null +++ b/site/src/FeatureTitle.jsx @@ -0,0 +1,37 @@ +import PropTypes from "prop-types"; + +export default function FeatureTitle({ entity }) { + if (entity.theme === "addresses") { + const addressParts = [entity.number, entity.unit, entity.street].filter( + Boolean + ); + + return addressParts.join(" "); + } + + return ( + entity["@name"] || + (entity["names"] && JSON.parse(entity["names"]).primary) || + `${entity["type"]}${ + entity["subtype"] && entity["subtype"] !== entity["type"] + ? ` (${entity["subtype"]})` + : "" + }` + ); +} + +FeatureTitle.propTypes = { + entity: PropTypes.shape({ + theme: PropTypes.string, + "@name": PropTypes.string, + names: PropTypes.string, + type: PropTypes.string, + subtype: PropTypes.string, + country: PropTypes.string, + postcode: PropTypes.string, + street: PropTypes.string, + number: PropTypes.string, + unit: PropTypes.string, + postal_city: PropTypes.string, + }).isRequired, +}; diff --git a/site/src/Map.jsx b/site/src/Map.jsx index a0171462..272dc56a 100644 --- a/site/src/Map.jsx +++ b/site/src/Map.jsx @@ -8,7 +8,7 @@ import "maplibre-gl/dist/maplibre-gl.css"; import * as pmtiles from "pmtiles"; import maplibregl from "maplibre-gl"; -import { Fragment, useState, useEffect, useCallback, useRef } from "react"; +import { useState, useEffect, useCallback, useRef } from "react"; import { Layer, GeolocateControl } from "react-map-gl/maplibre"; import InspectorPanel from "./inspector_panel/InspectorPanel"; import PropTypes from "prop-types"; @@ -18,6 +18,7 @@ import BugIcon from "./icons/icon-bug.svg?react"; import Navigator from "./navigator/Navigator"; import { layers } from "./Layers"; import ThemeTypeLayer from "./ThemeTypeLayer"; +import FeaturePopup from "./FeatureSelector"; const PMTILES_URL = "pmtiles://https://d3c1b7bog2u1nn.cloudfront.net/2024-11-13/"; @@ -49,20 +50,30 @@ ThemeSource.propTypes = { export default function Map({ mode, - mapEntity, - setMapEntity, + features, + setFeatures, + activeFeature, + setActiveFeature, setZoom, navigatorOpen, setNavigatorOpen, themeRef, }) { const mapRef = useRef(); + const [cursor, setCursor] = useState("auto"); - const [activeThemes, setActiveThemes] = useState(["places"]); + const [activeThemes, setActiveThemes] = useState([ + "places", + "addresses", + "buildings", + "transportation", + ]); const [visibleTypes, setVisibleTypes] = useState([]); const [interactiveLayerIds, setInteractiveLayerIds] = useState([]); + const [lastClickedCoords, setLastClickedCoords] = useState(); + // For access of latest value within map events const activeThemesRef = useRef(activeThemes); useEffect(() => { @@ -93,9 +104,6 @@ export default function Map({ ); const onMouseLeave = useCallback(() => setCursor("auto"), []); - const selectedSource = useRef(); - const selectedSourceLayer = useRef(); - useEffect(() => { const protocol = new pmtiles.Protocol(); maplibregl.addProtocol("pmtiles", protocol.tile); @@ -109,47 +117,59 @@ export default function Map({ window.map = mapRef.current; }); + const activeFeatureRef = useRef(null); + + useEffect(() => { + // Remove feature state from previous active feature + if (activeFeatureRef.current) { + mapRef.current.removeFeatureState({ + source: activeFeatureRef.current.source, + sourceLayer: activeFeatureRef.current.sourceLayer, + id: activeFeatureRef.current.id, + }); + } + + // Set feature state for new active feature + if (activeFeature) { + mapRef.current.setFeatureState( + { + source: activeFeature.source, + sourceLayer: activeFeature.sourceLayer, + id: activeFeature.id, + }, + { selected: true } + ); + } + + activeFeatureRef.current = activeFeature; + }, [activeFeature]); + const onClick = useCallback( (event) => { - let features = event.features; - const activeFeatures = []; - const backgroundFeatures = []; - features = features - .filter((f) => visibleTypes.indexOf(f.layer["source-layer"]) >= 0) - .forEach((f) => { - if (activeThemesRef.current.indexOf(f.layer["source"]) >= 0) { - activeFeatures.push(f); - } else { - backgroundFeatures.push(f); + setLastClickedCoords({ + longitude: event.lngLat.lng, + latitude: event.lngLat.lat, + }); + + const clickedFeatures = []; + const seenIds = new Set(); + + for (const feature of event.features) { + if (visibleTypes.indexOf(feature.layer["source-layer"]) >= 0) { + // Only add if we haven't seen this ID before + if (!seenIds.has(feature.properties.id)) { + clickedFeatures.push(feature); + seenIds.add(feature.properties.id); } - }); - const feature = - activeFeatures.length > 0 ? activeFeatures[0] : backgroundFeatures[0]; - if (feature) { - if (selectedSource.current) { - mapRef.current.removeFeatureState({ - source: selectedSource.current, - sourceLayer: selectedSourceLayer.current, - }); } + } - selectedSource.current = feature.source; - selectedSourceLayer.current = feature.sourceLayer; - mapRef.current.setFeatureState( - { - source: feature.source, - sourceLayer: feature.sourceLayer, - id: feature.id, - }, - { selected: true } - ); - setMapEntity({ - theme: feature.source, - type: feature.sourceLayer, - ...feature.properties, - }); + if (clickedFeatures.length > 0) { + setFeatures(clickedFeatures); + setActiveFeature(clickedFeatures[0]); } else { - setMapEntity({}); + setFeatures([]); + setActiveFeature(null); } }, [visibleTypes] @@ -189,6 +209,14 @@ export default function Map({ + setLastClickedCoords(null)} + setActiveFeature={setActiveFeature} + activeFeature={activeFeature} + /> + {[false, true].map((label) => { return layers.map((props, i) => ( - {Object.keys(mapEntity).length > 0 && ( + {features.length > 0 && ( )} ) : null} + {line ? ( + + ) : null} + {outline ? ( 1 - ? 0.35 - : 0.15 - : 0.15, - "fill-extrusion-base": ["coalesce",["get", "min_height"],0], - "fill-extrusion-height": ["coalesce",["get", "height"],0], + ? 0.35 + : 0.15 + : 0.15, + "fill-extrusion-base": ["coalesce", ["get", "min_height"], 0], + "fill-extrusion-height": ["coalesce", ["get", "height"], 0], }} layout={{ visibility: visible ? "visible" : "none" }} {...(minzoom ? { minzoom } : {})} diff --git a/site/src/Tour.jsx b/site/src/Tour.jsx index e481639e..1faf7646 100644 --- a/site/src/Tour.jsx +++ b/site/src/Tour.jsx @@ -148,7 +148,7 @@ const sampleFeature = { state: {}, }; -function Tour({ run, modeName, setMapEntity, setNavigatorOpen, themeRef }) { +function Tour({ run, modeName, setFeatures, setNavigatorOpen, themeRef }) { const [stepIndex, setStepIndex] = useState(0); const stepBGColor = @@ -210,7 +210,7 @@ function Tour({ run, modeName, setMapEntity, setNavigatorOpen, themeRef }) { (event.lifecycle === LIFECYCLE.COMPLETE) & (event.action === ACTIONS.NEXT) ) { - setMapEntity(sampleFeature.properties); + // setFeatures([{ properties: sampleFeature.properties }]); setTimeout(() => { setStepIndex(nextStepIndex); }, 100); @@ -218,7 +218,7 @@ function Tour({ run, modeName, setMapEntity, setNavigatorOpen, themeRef }) { (event.index === targets.indexOf(".inspector-panel")) & (event.lifecycle === LIFECYCLE.COMPLETE) ) { - setMapEntity({}); + // setFeatures({}); setStepIndex(nextStepIndex); } else if ( (event.index === targets.indexOf(".maplibregl-ctrl-bottom-right")) & @@ -228,7 +228,7 @@ function Tour({ run, modeName, setMapEntity, setNavigatorOpen, themeRef }) { setNavigatorOpen(true); setStepIndex(nextStepIndex); } else if (event.action === ACTIONS.PREV) { - setMapEntity(sampleFeature.properties); + // setFeatures(sampleFeature.properties); setTimeout(() => { setStepIndex(nextStepIndex); }, 100); @@ -237,7 +237,7 @@ function Tour({ run, modeName, setMapEntity, setNavigatorOpen, themeRef }) { setStepIndex(nextStepIndex); } } else if (event.action === ACTIONS.SKIP) { - if (event.index === targets.indexOf(".inspector-panel")) setMapEntity({}); + if (event.index === targets.indexOf(".inspector-panel")) setFeatures([]); else if ( (event.index === targets.indexOf(".tour-layers-checkboxes")) | (event.index === targets.indexOf(".tour-layers-pins")) diff --git a/site/src/inspector_panel/InfoToolTip.css b/site/src/inspector_panel/InfoToolTip.css index e31fa6b0..a4ad7c02 100644 --- a/site/src/inspector_panel/InfoToolTip.css +++ b/site/src/inspector_panel/InfoToolTip.css @@ -25,4 +25,5 @@ svg.info-icon { .info-floater-closed { pointer-events: none; + display: none; } diff --git a/site/src/inspector_panel/InfoToolTip.jsx b/site/src/inspector_panel/InfoToolTip.jsx index 6e172e1d..e29e0514 100644 --- a/site/src/inspector_panel/InfoToolTip.jsx +++ b/site/src/inspector_panel/InfoToolTip.jsx @@ -18,6 +18,7 @@ function InfoToolTip({ mode, target, content }) { mode === "theme-dark" ? "var(--ifm-navbar-background-color)" : "var(--ifm-color-secondary-light)", + display: open ? "block" : "none", }, arrow: { color: diff --git a/site/src/inspector_panel/InspectorPanel.css b/site/src/inspector_panel/InspectorPanel.css index 4b7c3f9c..10891289 100644 --- a/site/src/inspector_panel/InspectorPanel.css +++ b/site/src/inspector_panel/InspectorPanel.css @@ -16,6 +16,7 @@ border-color: rgba(15, 15, 15, 0.8); max-height: 80vh; position: relative; + overflow: auto; p { margin: 0; @@ -42,11 +43,12 @@ justify-content: space-between; grid-template-columns: auto auto; place-items: start; + height: 26px; } } .theme-dark .inspector-panel { - background-color: rgba(0, 0, 0, 0.75); + background-color: rgba(0, 0, 0, 0.85); box-shadow: 0 2px 4px black; color: white; diff --git a/site/src/inspector_panel/InspectorPanel.jsx b/site/src/inspector_panel/InspectorPanel.jsx index 87f2bf16..f9931a4f 100644 --- a/site/src/inspector_panel/InspectorPanel.jsx +++ b/site/src/inspector_panel/InspectorPanel.jsx @@ -14,15 +14,22 @@ import { function InspectorPanel({ mode, - entity, - setEntity, + setFeatures, activeThemes, setActiveThemes, + activeFeature, + setActiveFeature, }) { - if (!entity) { + if (!activeFeature) { return; } + const entity = { + theme: activeFeature.source, + type: activeFeature.sourceLayer, + ...activeFeature.properties, + }; + const theme = entity["theme"]; let inspectorPanel =
; @@ -111,7 +118,13 @@ function InspectorPanel({

Inspector Panel

-
diff --git a/site/src/inspector_panel/SourcesRow.css b/site/src/inspector_panel/SourcesRow.css new file mode 100644 index 00000000..10c17438 --- /dev/null +++ b/site/src/inspector_panel/SourcesRow.css @@ -0,0 +1,14 @@ +.sources-content { + margin-left: 20px; +} + +.source-divider { + margin: 2px 0; + border-top: 1px solid var(--ifm-table-border-width) solid + var(--ifm-table-border-color); +} + +.panel-row.sources { + overflow: auto; + max-height: 200px; +} diff --git a/site/src/inspector_panel/SourcesRow.jsx b/site/src/inspector_panel/SourcesRow.jsx new file mode 100644 index 00000000..033c1e06 --- /dev/null +++ b/site/src/inspector_panel/SourcesRow.jsx @@ -0,0 +1,88 @@ +import InfoToolTip from "./InfoToolTip"; +import "./SourcesRow.css"; + +function SourcesRow({ entity, mode, tips }) { + const sources = JSON.parse(entity["sources"]); + + const getSourceLink = (source) => { + try { + switch (source.dataset) { + case "meta": + return `https://facebook.com/${source.record_id}`; + case "OpenStreetMap": { + const match = source.record_id.match(/^([nwr])(\d+)(@\d+)?$/i); + if (!match) return null; + + const typeMap = { + n: "node", + w: "way", + r: "relation", + }; + + const type = typeMap[match[1].toLowerCase()]; + const id = match[2]; + + return `https://www.openstreetmap.org/${type}/${id}`; + } + default: + return null; + } + } catch (error) { + console.error("Error generating source link:", error); + return null; + } + }; + + return ( +
+
+ sources: +
+ {sources.map((source, index) => { + const url = getSourceLink(source); + return ( +
+
+ dataset: + {source.dataset} +
+
+ record_id: + {url ? ( + + {source.record_id} + + ) : ( + source.record_id + )} +
+ {source.update_time && ( +
+ update_time: + {source.update_time} +
+ )} + {source.property && ( +
+ property: + {source.property} +
+ )} + {index < sources.length - 1 && ( +
+ )} +
+ ); + })} +
+
+ +
+ ); +} + +export default SourcesRow; diff --git a/site/src/inspector_panel/TableRow.css b/site/src/inspector_panel/TableRow.css index ff2d282d..3894f545 100644 --- a/site/src/inspector_panel/TableRow.css +++ b/site/src/inspector_panel/TableRow.css @@ -11,8 +11,8 @@ word-break: break-all; } td:first-child { - text-align: left; - min-width: 40%; + text-align: right; + max-width: 40%; justify-content: space-between; word-break: normal; border: none; diff --git a/site/src/inspector_panel/ThemeIcon.jsx b/site/src/inspector_panel/ThemeIcon.jsx new file mode 100644 index 00000000..1bfd1233 --- /dev/null +++ b/site/src/inspector_panel/ThemeIcon.jsx @@ -0,0 +1,32 @@ +import LocationOnIcon from "@mui/icons-material/LocationOn"; +import HomeIcon from "@mui/icons-material/Home"; +import TerrainIcon from "@mui/icons-material/Terrain"; +import DirectionsIcon from "@mui/icons-material/Directions"; +import FlagIcon from "@mui/icons-material/Flag"; +import ImportContactsIcon from "@mui/icons-material/ImportContacts"; +import PropTypes from "prop-types"; + +function ThemeIcon({ theme }) { + switch (theme) { + case "places": + return ; + case "buildings": + return ; + case "base": + return ; + case "transportation": + return ; + case "divisions": + return ; + case "addresses": + return ; + default: + return <>; + } +} + +ThemeIcon.propTypes = { + theme: PropTypes.string.isRequired, +}; + +export default ThemeIcon; diff --git a/site/src/inspector_panel/ThemePanel.css b/site/src/inspector_panel/ThemePanel.css index 1159718d..5caf5c1e 100644 --- a/site/src/inspector_panel/ThemePanel.css +++ b/site/src/inspector_panel/ThemePanel.css @@ -1,5 +1,6 @@ div.theme-panel { width: 350px; + padding: 9px 0; } caption.common-props { diff --git a/site/src/inspector_panel/ThemePanel.jsx b/site/src/inspector_panel/ThemePanel.jsx index 0243c94c..dfef4c49 100644 --- a/site/src/inspector_panel/ThemePanel.jsx +++ b/site/src/inspector_panel/ThemePanel.jsx @@ -1,12 +1,11 @@ import TableRow from "./TableRow"; import "./ThemePanel.css"; -import ExpandLessIcon from "@mui/icons-material/ExpandLess"; -import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; -import { useState } from "react"; import IndentIcon from "../icons/icon-indent.svg?react"; import InfoToolTip from "./InfoToolTip"; import PushPinIcon from "@mui/icons-material/PushPin"; import PushPinOutlinedIcon from "@mui/icons-material/PushPinOutlined"; +import ThemeIcon from "./ThemeIcon"; +import SourcesRow from "./SourcesRow.jsx"; const sharedProperties = [ "theme", @@ -19,14 +18,22 @@ const sharedProperties = [ ]; function ThemePanel({ mode, entity, tips, activeThemes, setActiveThemes }) { - const [commonExpanded, setCommonExpanded] = useState(false); - const [otherExpanded, setOtherExpanded] = useState(false); - return (
+ {entity["id"] ? ( +
+
+ id: + {entity["id"]} +
+ +
+ ) : ( + <> + )}
- Theme: + theme: {entity["theme"]}
@@ -57,7 +64,7 @@ function ThemePanel({ mode, entity, tips, activeThemes, setActiveThemes }) {
- Type: + type: {entity["type"]}
- Subtype: + subtype: {entity["subtype"]}
)} - {entity["id"] ? ( -
-
- ID: - {entity["id"]} -
- -
- ) : ( - <> - )} {entity["sources"] ? ( -
-
- Source(s):{" "} - {[...new Set(JSON.parse(entity["sources"]).map((source) => source["dataset"]))].join(', ')} -
- -
+ ) : ( <> )} - {entity["class"] ? ( -
+ {["version"].map((key) => ( +
- Class: {entity["class"]} + {key}: + {entity[key]}
- {" "}
- ) : ( - "" - )} -
- - {" "} - {commonExpanded ? ( - - {["update_time", "version"].map((key) => ( - - ))} - - ) : ( - - )} -
- -
-
-
- - {" "} - {otherExpanded ? ( - - {Object.keys(entity) - .filter((key) => !key.startsWith("@")) - .filter((key) => !sharedProperties.includes(key)) - .map((key) => ( - - ))} - - ) : ( - - )} -
- -
-
+ ))} + {Object.keys(entity) + .filter((key) => !key.startsWith("@")) + .filter((key) => !sharedProperties.includes(key)) + .map((key) => ( + + ))}
); } diff --git a/site/src/navigator/Navigator.jsx b/site/src/navigator/Navigator.jsx index 29a86ef6..e3626ac4 100644 --- a/site/src/navigator/Navigator.jsx +++ b/site/src/navigator/Navigator.jsx @@ -4,6 +4,20 @@ import ArrowForwardIosIcon from "@mui/icons-material/ArrowForwardIos"; import FlightTakeoffIcon from "@mui/icons-material/FlightTakeoff"; import { useMap } from "react-map-gl/maplibre"; import { tours } from "./NavigatorConfig"; +import { useState, useEffect } from "react"; + +export function useNavigatorState(initialOpen = false) { + const [navigatorOpen, setNavigatorOpen] = useState(() => { + const stored = localStorage.getItem("navigatorOpen"); + return stored !== null ? JSON.parse(stored) : !initialOpen; + }); + + useEffect(() => { + localStorage.setItem("navigatorOpen", JSON.stringify(navigatorOpen)); + }, [navigatorOpen]); + + return [navigatorOpen, setNavigatorOpen]; +} function Navigator({ open, setOpen, map, setVisibleTypes, setActiveThemes }) { const { myMap } = useMap();