Skip to content

Commit

Permalink
Implement demo for client side routing based on wouter
Browse files Browse the repository at this point in the history
  • Loading branch information
mbeckem committed Feb 7, 2025
1 parent c6432a4 commit e81ad97
Show file tree
Hide file tree
Showing 8 changed files with 316 additions and 326 deletions.
2 changes: 0 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@
"",
"patches",
"=======",
"react-select: patched to use composedPath() in event conditions; needed for web component integration (see https://github.com/JedWatson/react-select/issues/5824)",
"@chakra-ui/menu;react-use-outside-click;hooks: patched to fix menus in shadow dom (see https://github.com/open-pioneer/trails-openlayers-base-packages/issues/184)",
"",
"peer dependency rules",
Expand All @@ -64,7 +63,6 @@
"ignoreCves": []
},
"patchedDependencies": {
"[email protected]": "patches/[email protected]",
"@chakra-ui/[email protected]": "patches/@chakra-ui__hooks.patch"
},
"peerDependencyRules": {}
Expand Down
239 changes: 44 additions & 195 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ catalog:
"@open-pioneer/integration": *core_packages_version
"@open-pioneer/notifier": *core_packages_version
"@open-pioneer/react-utils": *core_packages_version
"@open-pioneer/reactivity": *core_packages_version
"@open-pioneer/runtime-react-support": *core_packages_version
"@open-pioneer/runtime": *core_packages_version
"@open-pioneer/test-utils": *core_packages_version
Expand Down Expand Up @@ -96,3 +97,4 @@ catalog:
vite-plugin-eslint: ^1.8.1
vite: ^5.4.14
vitest: ^2.1.9
wouter: ^3.5.1
326 changes: 203 additions & 123 deletions src/samples/map-sample/ol-app/MapApp.tsx
Original file line number Diff line number Diff line change
@@ -1,45 +1,56 @@
// SPDX-FileCopyrightText: 2023 Open Pioneer project (https://github.com/open-pioneer)
// SPDX-License-Identifier: Apache-2.0
import { Box, Divider, Flex, FormControl, FormLabel, Text } from "@open-pioneer/chakra-integration";
import { DefaultMapProvider, MapAnchor, MapContainer } from "@open-pioneer/map";
import { ScaleBar } from "@open-pioneer/scale-bar";
import { InitialExtent, ZoomIn, ZoomOut } from "@open-pioneer/map-navigation";
import { useIntl } from "open-pioneer:react-hooks";
import {
Box,
chakra,
Drawer,
DrawerBody,
DrawerCloseButton,
DrawerContent,
DrawerFooter,
DrawerHeader,
Flex,
HStack,
Link,
List,
ListItem
} from "@open-pioneer/chakra-integration";
import { CoordinateViewer } from "@open-pioneer/coordinate-viewer";
import {
BaseFeature,
DefaultMapProvider,
MapAnchor,
MapContainer,
MapModel,
useMapModel
} from "@open-pioneer/map";
import { InitialExtent, ZoomIn, ZoomOut } from "@open-pioneer/map-navigation";
import { NotificationService, Notifier } from "@open-pioneer/notifier";
import { SectionHeading, TitledSection } from "@open-pioneer/react-utils";
import { ToolButton } from "@open-pioneer/map-ui-components";
import { useReactiveSnapshot } from "@open-pioneer/reactivity";
import { ScaleBar } from "@open-pioneer/scale-bar";
import { ScaleViewer } from "@open-pioneer/scale-viewer";
import { Geolocation } from "@open-pioneer/geolocation";
import { Notifier } from "@open-pioneer/notifier";
import { OverviewMap } from "@open-pioneer/overview-map";
import { Point, Polygon } from "ol/geom";
import { useIntl, useService } from "open-pioneer:react-hooks";
import { ReactNode, useEffect, useMemo } from "react";
import { Link as WouterLink, Router, useLocation } from "wouter";
import { getFeatureUrl, useCurrentFeatureId, useRouterOptions } from "./routes";
import { MAP_ID } from "./services";
import { useId, useMemo, useState } from "react";
import TileLayer from "ol/layer/Tile";
import { Measurement } from "@open-pioneer/measurement";
import OSM from "ol/source/OSM";
import { PiRulerLight } from "react-icons/pi";
import { BasemapSwitcher } from "@open-pioneer/basemap-switcher";

export function MapApp() {
const intl = useIntl();
const measurementTitleId = useId();
const { map } = useMapModel(MAP_ID);

const [measurementIsActive, setMeasurementIsActive] = useState<boolean>(false);
function toggleMeasurement() {
setMeasurementIsActive(!measurementIsActive);
}

const overviewMapLayer = useMemo(
() =>
new TileLayer({
source: new OSM()
}),
[]
);
const linkIds = ["1", "2", "123", undefined];
const links = linkIds.map((id) => (
<WouterLink key={String(id)} href={getFeatureUrl(id)} asChild>
<Link>{id ? `Select ${id}` : "Reset"}</Link>
</WouterLink>
));

return (
<Flex height="100%" direction="column" overflow="hidden">
<Notifier position="bottom" />
<Notifier position="top-right" />
<TitledSection
title={
<Box
Expand All @@ -49,105 +60,174 @@ export function MapApp() {
py={1}
>
<SectionHeading size={"md"}>
Open Pioneer Trails - Map Sample
Open Pioneer Trails - Map with routing
</SectionHeading>
</Box>
}
>
<DefaultMapProvider mapId={MAP_ID}>
<Flex flex="1" direction="column" position="relative">
<MapContainer
role="main"
aria-label={intl.formatMessage({ id: "ariaLabel.map" })}
>
<MapAnchor position="top-left" horizontalGap={5} verticalGap={5}>
{measurementIsActive && (
<Box
backgroundColor="white"
borderWidth="1px"
borderRadius="lg"
padding={2}
boxShadow="lg"
role="top-left"
aria-label={intl.formatMessage({ id: "ariaLabel.topLeft" })}
>
<Box role="dialog" aria-labelledby={measurementTitleId}>
<TitledSection
title={
<SectionHeading
id={measurementTitleId}
size="md"
mb={2}
>
{intl.formatMessage({
id: "measurementTitle"
})}
</SectionHeading>
}
>
<Measurement />
</TitledSection>
</Box>
</Box>
)}
</MapAnchor>
<MapAnchor position="top-right" horizontalGap={5} verticalGap={5}>
<Box
backgroundColor="white"
borderWidth="1px"
borderRadius="lg"
padding={2}
boxShadow="lg"
role="top-right"
aria-label={intl.formatMessage({ id: "ariaLabel.topRight" })}
>
<OverviewMap olLayer={overviewMapLayer} />
<Divider mt={4} />
<FormControl>
<FormLabel mt={2}>
<Text as="b">
{intl.formatMessage({ id: "basemapLabel" })}
</Text>
</FormLabel>
<BasemapSwitcher allowSelectingEmptyBasemap />
</FormControl>
</Box>
</MapAnchor>
<MapAnchor position="bottom-right" horizontalGap={10} verticalGap={30}>
<Flex
role="bottom-right"
aria-label={intl.formatMessage({ id: "ariaLabel.bottomRight" })}
direction="column"
gap={1}
padding={1}
>
<ToolButton
label={intl.formatMessage({ id: "measurementTitle" })}
icon={<PiRulerLight />}
isActive={measurementIsActive}
onClick={toggleMeasurement}
/>
<Geolocation />
<InitialExtent />
<ZoomIn />
<ZoomOut />
</Flex>
</MapAnchor>
</MapContainer>
</Flex>
<Flex
role="region"
aria-label={intl.formatMessage({ id: "ariaLabel.footer" })}
gap={3}
alignItems="center"
justifyContent="center"
>
<CoordinateViewer precision={2} />
<ScaleBar />
<ScaleViewer />
</Flex>
</DefaultMapProvider>
<Router {...useRouterOptions()}>
<HStack alignSelf="center" gap={4} my={2}>
{links}
</HStack>
{map && <AppContent map={map} />}
</Router>
</TitledSection>
</Flex>
);
}

const DRAWER_WIDTH = 400; // pixels

function AppContent(props: { map: MapModel }) {
const { map } = props;
const intl = useIntl();
const [, navigate] = useLocation();
const drawerContent = useFeatureSelection(map);

return (
<DefaultMapProvider map={map}>
{drawerContent && (
<Drawer
isOpen={true}
onClose={() => {
navigate(getFeatureUrl(undefined));
}}
placement="left"
variant={"clickThrough"}
closeOnOverlayClick={false}
closeOnEsc={false}
blockScrollOnMount={false}
>
{drawerContent}
</Drawer>
)}
<Flex flex="1" direction="column" position="relative">
<MapContainer
role="main"
aria-label={intl.formatMessage({ id: "ariaLabel.map" })}
viewPadding={drawerContent ? { left: DRAWER_WIDTH } : undefined}
>
<MapAnchor position="bottom-right" horizontalGap={10} verticalGap={30}>
<Flex
role="bottom-right"
aria-label={intl.formatMessage({ id: "ariaLabel.bottomRight" })}
direction="column"
gap={1}
padding={1}
>
<InitialExtent />
<ZoomIn />
<ZoomOut />
</Flex>
</MapAnchor>
</MapContainer>
</Flex>
<Flex
role="region"
aria-label={intl.formatMessage({ id: "ariaLabel.footer" })}
gap={3}
alignItems="center"
justifyContent="center"
>
<CoordinateViewer precision={2} />
<ScaleBar />
<ScaleViewer />
</Flex>
</DefaultMapProvider>
);
}

/**
* Handles feature selection logic.
* We can select (at most) one feature at a time via URL state.
*
* If the feature is found, it is highlighted in the map and the content for the drawer is returned from this hook.
* If the feature cannot be found, a notification is emitted (and no drawer content is returned).
*/
function useFeatureSelection(map: MapModel): ReactNode {
const selectedFeatureId = useCurrentFeatureId();
const selectedFeature = selectedFeatureId != null ? FEATURES[selectedFeatureId] : undefined;
const notifier = useService<NotificationService>("notifier.NotificationService");
const mapIsReady = useReactiveSnapshot(() => !!map.container, [map]);

// Emit a notification if the feature cannot be found.
useEffect(() => {
if (selectedFeatureId && !selectedFeature) {
notifier.warning(`Feature '${selectedFeatureId}' not found`);
}
}, [notifier, selectedFeature, selectedFeatureId]);

// Highlight the selected feature.
useEffect(() => {
if (!selectedFeature || !mapIsReady) {
return;
}

const highlight = map.highlightAndZoom([selectedFeature], {
viewPadding: {
bottom: 50,
left: 50,
right: 50,
top: 50
}
});
return () => highlight.destroy();
}, [map, mapIsReady, selectedFeature]);

const drawerContent = useMemo(() => {
if (!selectedFeature) {
return undefined;
}

const title = `Feature ${selectedFeature.id}`;
const properties = Object.entries(selectedFeature.properties ?? {}).map(([key, value]) => (
<ListItem key={key}>
{key}: {String(value)}
</ListItem>
));

return (
<DrawerContent maxW={`${DRAWER_WIDTH}px`} background={"whiteAlpha.800"}>
<DrawerCloseButton />
<DrawerHeader>{title}</DrawerHeader>

<DrawerBody>
<chakra.strong display="block">Properties:</chakra.strong>
{properties.length > 0 ? <List>{properties}</List> : "No properties"}
</DrawerBody>

<DrawerFooter></DrawerFooter>
</DrawerContent>
);
}, [selectedFeature]);

return drawerContent;
}

const FEATURES: Record<string, BaseFeature> = {
"1": {
id: "1",
geometry: new Polygon([
[
[851728.251553, 6788384.425292],
[851518.049725, 6788651.954891],
[852182.096409, 6788881.265976],
[851728.251553, 6788384.425292]
]
]),
properties: {
name: "Feature 1",
description: "This is the first feature",
area: 100
}
},
"2": {
id: "2",
geometry: new Point([852011.307424, 6788511.322702]),
properties: {
name: "Feature 2",
description: "This is the second feature",
area: 0
}
}
};
Loading

0 comments on commit e81ad97

Please sign in to comment.