Skip to content

Commit

Permalink
Merge pull request #156 from sproutverse/feat/entrance-normals
Browse files Browse the repository at this point in the history
Place entrance letters within buildings (#155)
  • Loading branch information
haphut authored Jun 10, 2020
2 parents 39e5eac + ec1c30c commit b035c72
Show file tree
Hide file tree
Showing 6 changed files with 379 additions and 64 deletions.
4 changes: 4 additions & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
134 changes: 108 additions & 26 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -49,6 +52,7 @@ interface State {
geolocationPosition: LatLng | null;
popupCoordinates: ElementWithCoordinates | null;
snackbar?: ReactText;
routableTiles: Map<string, FeatureCollection | null>;
}

const latLngToDestination = (latLng: LatLng): ElementWithCoordinates => ({
Expand Down Expand Up @@ -81,6 +85,7 @@ const initialState: State = {
isGeolocating: false,
geolocationPosition: null,
popupCoordinates: null,
routableTiles: new Map(),
};

const metropolitanAreaCenter = [60.17066815612902, 24.941510260105133];
Expand Down Expand Up @@ -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]);
Expand All @@ -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
Expand Down Expand Up @@ -603,26 +683,28 @@ const App: React.FC = () => {
<UserPosition dataTestId="user-marker" />
</Marker>
)}
<Source
id="osm-qa-tiles"
type="vector"
tiles={["https://tile.olmap.org/osm-qa-tiles/{z}/{x}/{y}.pbf"]}
minzoom={12}
maxzoom={12}
>
<Layer
source-layer="osm"
// eslint-disable-next-line react/jsx-props-no-spreading
{...allEntrancesLayer}
source="osm-qa-tiles"
/>
<Layer
source-layer="osm"
// eslint-disable-next-line react/jsx-props-no-spreading
{...allEntrancesSymbolLayer}
source="osm-qa-tiles"
/>
</Source>
{Array.from(
state.routableTiles.entries(),
([coords, tile]) =>
tile && (
<Source
key={coords}
id={`source-${coords}`}
type="geojson"
data={tile}
>
{allEntrancesLayers.map((layer) => (
<Layer
// eslint-disable-next-line react/jsx-props-no-spreading
{...layer}
key={`${layer.id}-${coords}`}
id={`${layer.id}-${coords}`}
source={`source-${coords}`}
/>
))}
</Source>
)
)}
<Source id="highlights" type="geojson" data={state.highlights}>
<Layer
// eslint-disable-next-line react/jsx-props-no-spreading
Expand Down
138 changes: 138 additions & 0 deletions src/RoutableTilesToGeoJSON.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
// Original source: https://github.com/openplannerteam/leaflet-routable-tiles/blob/master/lib/RoutableTilesToGeoJSON.js

import {
bearing as turfBearing,
lineString as turfLineString,
booleanClockwise as turfBooleanClockwise,
} from "@turf/turf";

const offset = 0.2;

var extractWays = function (json, nodes, feats) {
json["@graph"]
.filter((item) => {
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),
};
}
13 changes: 13 additions & 0 deletions src/components/Triangle.tsx
Original file line number Diff line number Diff line change
@@ -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 => `
<svg xmlns="http://www.w3.org/2000/svg"
width="${size}px"
height="${size}px"
style="${style}"
viewBox="${SVG_VIEWBOX}"
>
<path d="${SVG_PATH}" />
</svg>`;
Loading

0 comments on commit b035c72

Please sign in to comment.