diff --git a/.eslintignore b/.eslintignore index d895d7c1..1e39cdea 100644 --- a/.eslintignore +++ b/.eslintignore @@ -11,3 +11,7 @@ src/serviceWorker.ts # Copied from @urbica/react-map-gl src/__mocks__/mapbox-gl.js + +# Modules from external sources +src/minimal-xyz-viewer.js +src/RoutableTilesToGeoJSON.js diff --git a/src/App.tsx b/src/App.tsx index 6a48606c..c571b6e7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,16 +20,19 @@ import { routePointSymbolLayer, routeLineLayer, routeImaginaryLineLayer, - allEntrancesLayer, - allEntrancesSymbolLayer, buildingHighlightLayer, + allEntrancesLayers, } from "./map-style"; import Pin, { pinAsSVG } from "./components/Pin"; +import { triangleAsSVG } from "./components/Triangle"; import UserPosition from "./components/UserPosition"; import GeolocateControl from "./components/GeolocateControl"; import calculatePlan, { geometryToGeoJSON } from "./planner"; import { queryEntrances, ElementWithCoordinates } from "./overpass"; import { addImageSVG, getMapSize } from "./mapbox-utils"; +import routableTilesToGeoJSON from "./RoutableTilesToGeoJSON"; +import { getVisibleTiles } from "./minimal-xyz-viewer"; + import "./App.css"; import "./components/PinMarker.css"; @@ -49,6 +52,7 @@ interface State { geolocationPosition: LatLng | null; popupCoordinates: ElementWithCoordinates | null; snackbar?: ReactText; + routableTiles: Map; } const latLngToDestination = (latLng: LatLng): ElementWithCoordinates => ({ @@ -81,6 +85,7 @@ const initialState: State = { isGeolocating: false, geolocationPosition: null, popupCoordinates: null, + routableTiles: new Map(), }; const metropolitanAreaCenter = [60.17066815612902, 24.941510260105133]; @@ -157,11 +162,19 @@ const App: React.FC = () => { // FIXME: Unclear why this passed type checking before. // eslint-disable-next-line @typescript-eslint/no-explicit-any mapboxgl?.on("styleimagemissing", ({ id: iconId }: any) => { - if (!iconId?.startsWith("icon-pin-")) { - return; // We only know how to generate pin icons + if (!iconId?.startsWith("icon-svg-")) { + return; // We only know how to generate certain svg icons + } + const [, , shape, size, fill, stroke] = iconId.split("-"); // e.g. icon-pin-48-green-#fff + let renderSVG; + if (shape === "pin") { + renderSVG = pinAsSVG; + } else if (shape === "triangle") { + renderSVG = triangleAsSVG; + } else { + return; // Unknown shape } - const [, , size, fill, stroke] = iconId.split("-"); // e.g. icon-pin-48-green-#fff - const svgData = pinAsSVG(size, `fill: ${fill}; stroke: ${stroke}`); + const svgData = renderSVG(size, `fill: ${fill}; stroke: ${stroke}`); addImageSVG(mapboxgl, iconId, svgData, size); }); }, [map]); @@ -182,6 +195,73 @@ const App: React.FC = () => { ); }; + useEffect(() => { + if (!map.current || !state.viewport.zoom) { + return; // Nothing to do yet + } + if (state.viewport.zoom < 12) return; // minzoom + + const { width: mapWidth, height: mapHeight } = getMapSize( + map.current.getMap() + ); + + // Calculate multiplier for under- or over-zoom + const tilesetZoomLevel = 14; + const zoomOffset = 1; // tiles are 512px (double the standard size) + const zoomMultiplier = + 2 ** (tilesetZoomLevel - zoomOffset - state.viewport.zoom); + + const visibleTiles = getVisibleTiles( + zoomMultiplier * mapWidth, + zoomMultiplier * mapHeight, + [state.viewport.longitude, state.viewport.latitude], + tilesetZoomLevel + ); + + // Initialise the new Map with nulls and available tiles from previous + const routableTiles = new Map(); + visibleTiles.forEach(({ zoom, x, y }) => { + const key = `${zoom}/${x}/${y}`; + routableTiles.set(key, state.routableTiles.get(key) || null); + }); + + setState( + (prevState: State): State => { + return { + ...prevState, + routableTiles, + }; + } + ); + + visibleTiles.map(async ({ zoom, x, y }) => { + const key = `${zoom}/${x}/${y}`; + if (routableTiles.get(key) !== null) return; // We already have the tile + // Fetch the tile + const response = await fetch( + `https://tile.olmap.org/building-tiles/${zoom}/${x}/${y}` + ); + const body = await response.json(); + // Convert the tile to GeoJSON + const geoJSON = routableTilesToGeoJSON(body) as FeatureCollection; + // Add the tile if still needed based on latest state + setState( + (prevState: State): State => { + if (prevState.routableTiles.get(key) !== null) { + return prevState; // This tile is not needed anymore + } + const newRoutableTiles = new Map(prevState.routableTiles); + newRoutableTiles.set(key, geoJSON); + return { + ...prevState, + routableTiles: newRoutableTiles, + }; + } + ); + }); + }, [map.current, state.viewport]); // eslint-disable-line react-hooks/exhaustive-deps + // XXX: state.routableTiles is missing above as we only use it as a cache here + useEffect(() => { /** * FIXME: urbica/react-map-gl does not expose fitBounds and its viewport @@ -603,26 +683,28 @@ const App: React.FC = () => { )} - - - - + {Array.from( + state.routableTiles.entries(), + ([coords, tile]) => + tile && ( + + {allEntrancesLayers.map((layer) => ( + + ))} + + ) + )} { + return ( + item["@type"] === "osm:Way" + /* FIXME: Implement proper support for multipolygon outlines + and then re-enable the following filter: + && item["osm:hasTag"]?.find((tag) => tag.startsWith("building=")) + */ + ); + }) + .forEach((item) => { + //Transform osm:hasNodes to a linestring style thing + if (!item["osm:hasNodes"]) { + item["osm:hasNodes"] = []; + } else if (typeof item["osm:hasNodes"] === "string") { + item["osm:hasNodes"] = [item["osm:hasNodes"]]; + } + const nodeIds = item["osm:hasNodes"]; + if (nodeIds.length < 4) { + console.log("not an area", item["@id"]); + return; + } + if (nodeIds[0] !== nodeIds[nodeIds.length - 1]) { + console.log("unclosed", item["@id"]); + } + item["osm:hasNodes"].map((nodeId, index, nodeIds) => { + const node = feats[nodeId]; + if (node) { + // FIXME: This logic does not consider inner edges of multipolygons: + const isWayClockwise = turfBooleanClockwise( + turfLineString(nodeIds.map((id) => nodes[id])) + ); + const xy = nodes[nodeId]; + const xyPrev = + index === 0 + ? nodes[nodeIds[nodeIds.length - 2]] + : nodes[nodeIds[index - 1]]; + const xyNext = + index === nodeIds.length - 1 + ? nodes[nodeIds[1]] + : nodes[nodeIds[index + 1]]; + + const bearingPrev = turfBearing(xy, xyPrev); + const bearingNext = turfBearing(xy, xyNext); + const entranceAngle = + Math.abs(bearingPrev - bearingNext) / 2 + + Math.min(bearingPrev, bearingNext); + const adaptedAngle = + bearingPrev > bearingNext + ? entranceAngle + 270 + : entranceAngle + 90; + const angle = isWayClockwise ? adaptedAngle : adaptedAngle + 180; + + node.properties = { + ...node.properties, + "@offset": [ + Math.cos((angle / 180) * Math.PI) * offset, + Math.sin((angle / 180) * Math.PI) * offset, + ], + "@rotate": (((angle - 90) % 360) + 360) % 360, + }; + } + return nodes[nodeId]; + }); + }); +}; + +const entranceToLabel = function (node) { + const house = + node["osm:hasTag"] + ?.find((tag) => tag.startsWith("addr:housenumber=")) + ?.substring(17) || ""; + const ref = node["osm:hasTag"] + ?.find((tag) => tag.startsWith("ref=")) + ?.substring(4); + const unit = node["osm:hasTag"] + ?.find((tag) => tag.startsWith("addr:unit=")) + ?.substring(10); + const entrance = ref || unit || ""; + const separator = house && entrance ? " " : ""; + const label = `${house}${separator}${entrance}`.replace(/ /g, "\u2009"); + return label; +}; + +export default function (json) { + var entrances = {}; + var nodes = {}; + for (var i = 0; i < json["@graph"].length; i++) { + let element = json["@graph"][i]; + if (element["geo:lat"] && element["geo:long"]) { + // Store the coordinates of every node for later reference + const lngLat = [element["geo:long"], element["geo:lat"]]; + nodes[element["@id"]] = lngLat; + + if (element["osm:hasTag"]?.find((tag) => tag.startsWith("entrance="))) { + // Create a GeoJSON feature for each entrance + const entrance = { + id: element["@id"], + type: "Feature", + geometry: { + type: "Point", + coordinates: lngLat, + }, + properties: { + "@id": element["@id"], + "@label": entranceToLabel(element), + }, + }; + // Store each OSM tag as a feature property + // XXX: Could be computed later on demand + element["osm:hasTag"].forEach((tag) => { + const splitIndex = tag.indexOf("="); + entrance.properties[tag.substring(0, splitIndex)] = tag.substring( + splitIndex + 1 + ); + }); + + entrances[entrance.properties["@id"]] = entrance; + } + } + } + extractWays(json, nodes, entrances); + return { + type: "FeatureCollection", + features: Object.values(entrances), + }; +} diff --git a/src/components/Triangle.tsx b/src/components/Triangle.tsx new file mode 100644 index 00000000..0fb40979 --- /dev/null +++ b/src/components/Triangle.tsx @@ -0,0 +1,13 @@ +const SVG_VIEWBOX = "-5 -1 25 25"; +const SVG_PATH = "M 8 16 L 0 8 L 16 8 Z"; + +// eslint-disable-next-line import/prefer-default-export +export const triangleAsSVG = (size: number, style: string): string => ` + + +`; diff --git a/src/map-style.ts b/src/map-style.ts index f15fb386..51a3ed98 100644 --- a/src/map-style.ts +++ b/src/map-style.ts @@ -1,4 +1,32 @@ import type { LayerProps } from "react-map-gl"; +import type { Expression } from "mapbox-gl"; + +const anglesToAnchors = (): Array => { + const offset = 0; + const angles = [22.5, 67.5, 112.5, 157.5, 202.5, 247.7, 292.5, 337.5]; + + const anchors = [ + "top", + "top-right", + "right", + "bottom-right", + "bottom", + "bottom-left", + "left", + "top-left", + ]; + + const initialStep: Array = [anchors[offset]]; + const ret = initialStep.concat( + angles.flatMap((angle, index) => { + return [angle, anchors[(offset + index + 1) % anchors.length]] as Array< + string | number + >; + }) + ); + + return ret; +}; export const routeLineLayer: LayerProps = { id: "route-line", @@ -51,46 +79,54 @@ export const buildingHighlightLayer: LayerProps = { }, }; -export const allEntrancesLayer: LayerProps = { - id: "entrance-point", - type: "circle", - minzoom: 12, - paint: { - "circle-radius": [ - "interpolate", - ["linear"], - ["zoom"], - 12, // At zoom 12 or less, - 1, // circle radius is 1. - 14, // At zoom 14, - 3, // circle radius is 3. - 15, // At zoom 15 or more, - 5, // circle radius is 5. - ], - "circle-color": "#64be14", +export const allEntrancesLayers: Array = [ + { + id: "entrance-point", + type: "circle", + minzoom: 12, + maxzoom: 15.999, + paint: { + "circle-radius": [ + "interpolate", + ["linear"], + ["zoom"], + 12, // At zoom 12 or less, + 1, // circle radius is 1. + 14, // At zoom 14, + 2, // circle radius is 2. + 15, // At zoom 15 or more, + 3, // circle radius is 3. + ], + "circle-color": "#64be14", + }, }, - filter: ["has", "entrance"], -}; - -export const allEntrancesSymbolLayer: LayerProps = { - id: "entrance-symbol", - type: "symbol", - minzoom: 16, - paint: { - "text-halo-color": "#fff", - "text-color": "#64be14", - "text-halo-width": 3, + { + id: "entrance-symbol", + type: "symbol", + minzoom: 16, + paint: { + "text-halo-color": "#fff", + "text-color": "#64be14", + "text-halo-width": 1, + }, + layout: { + "text-field": ["get", "@label"], + "text-font": ["Klokantech Noto Sans Regular"], + "text-size": 16, + "text-offset": ["get", "@offset"], + "text-anchor": ["step", ["%", ["get", "@rotate"], 360]].concat( + anglesToAnchors() + ) as Expression, + "text-allow-overlap": true, + "text-ignore-placement": true, + "icon-image": "icon-svg-triangle-14-#64be14-#fff", + "icon-anchor": "bottom", + "icon-rotate": ["get", "@rotate"], + "icon-allow-overlap": true, + "icon-ignore-placement": true, + }, }, - layout: { - "text-field": ["coalesce", ["get", "ref"], ["get", "addr:unit"]], - "text-anchor": "center", - "text-font": ["Klokantech Noto Sans Regular"], - "text-size": 24, - "text-offset": [0, -0.8], - "text-allow-overlap": true, - }, - filter: ["has", "entrance"], -}; +]; export const routePointSymbolLayer: LayerProps = { id: "route-point-symbol", diff --git a/src/minimal-xyz-viewer.js b/src/minimal-xyz-viewer.js new file mode 100644 index 00000000..4fc890bc --- /dev/null +++ b/src/minimal-xyz-viewer.js @@ -0,0 +1,42 @@ +// Source: https://gist.github.com/jsanz/e8549c7bffd442235a942695ffdaf77d + +const TILE_SIZE = 256; +const WEBMERCATOR_R = 6378137.0; +const DIAMETER = WEBMERCATOR_R * 2 * Math.PI; + +export function mercatorProject(lonlat) { + var x = (DIAMETER * lonlat[0]) / 360.0; + var sinlat = Math.sin((lonlat[1] * Math.PI) / 180.0); + var y = (DIAMETER * Math.log((1 + sinlat) / (1 - sinlat))) / (4 * Math.PI); + return [DIAMETER / 2 + x, DIAMETER - (DIAMETER / 2 + y)]; +} +// console.log(Mercator.project([-3,41])) + +export function getVisibleTiles(clientWidth, clientHeight, center, zoom) { + var centerm = mercatorProject(center); + // zoom + centerm -> centerpx + var centerpx = [ + (centerm[0] * TILE_SIZE * Math.pow(2, zoom)) / DIAMETER, + (centerm[1] * TILE_SIZE * Math.pow(2, zoom)) / DIAMETER, + ]; + + // xmin, ymin, xmax, ymax + var bbox = [ + Math.floor((centerpx[0] - clientWidth / 2) / TILE_SIZE), + Math.floor((centerpx[1] - clientHeight / 2) / TILE_SIZE), + Math.ceil((centerpx[0] + clientWidth / 2) / TILE_SIZE), + Math.ceil((centerpx[1] + clientHeight / 2) / TILE_SIZE), + ]; + var tiles = []; + //xmin, ymin, xmax, ymax + for (let x = bbox[0]; x < bbox[2]; ++x) { + for (let y = bbox[1]; y < bbox[3]; ++y) { + var [px, py] = [ + x * TILE_SIZE - centerpx[0] + clientWidth / 2, + y * TILE_SIZE - centerpx[1] + clientHeight / 2, + ]; + tiles.push({ x, y, zoom, px, py }); + } + } + return tiles; +}