Skip to content

Commit

Permalink
feat(map): improve device map for multiple locations
Browse files Browse the repository at this point in the history
This changes the behaviour of the device map so that
it no longer zooms to a level that includes all device
locations, but only zooms on the clicked on.

This was changed because some devices can have old,
far distant locations (e.g. single-cell) which no longer
is relevant.

Now the device map centers and zooms on the most recent
location and remembers which location is supposed to be
displayed (by source).

This also adds a button to center the device map on each
individual location source.

Finally, the tolerance for the GeoJSON object that renders
the different location sources is lowered so that the
hexagon is displayed at any zoom level.

Fixes #381
See #378
  • Loading branch information
coderbyheart committed Dec 18, 2024
1 parent 0e5e978 commit e3bb11a
Show file tree
Hide file tree
Showing 11 changed files with 175 additions and 35 deletions.
1 change: 0 additions & 1 deletion src/MapApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ export const MapApp = () => {
// However, this app's sidebar should not overflow the viewport.
const appHeight = `${window.innerHeight}px`
document.documentElement.style.setProperty('--app-height', appHeight)
console.debug(`[MapApp]`, 'appHeight:', appHeight)
})
return (
<div id="layout">
Expand Down
8 changes: 6 additions & 2 deletions src/component/AllDevicesMap/AllDevicesMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,12 @@ export const AllDevicesMap = () => {
'click',
'devices-dots',
(e: MapMouseEvent & { features?: MapGeoJSONFeature[] }) => {
const id = e.features?.[0]?.properties.id
const { id, source } = e.features?.[0]?.properties ?? {}
location.navigate({
panel: `id:${id}`,
deviceMap: {
centerLocationSource: source,
},
})
},
)
Expand Down Expand Up @@ -139,7 +142,7 @@ export const AllDevicesMap = () => {
({
device: { id },
location: {
Resources: { 0: lat, 1: lng },
Resources: { 0: lat, 1: lng, 6: source },
},
resources,
}) => ({
Expand All @@ -153,6 +156,7 @@ export const AllDevicesMap = () => {
resourceValues: resources
.map(({ value, units }) => `${value} ${units ?? ''}`)
.join('\n'),
source,
},
}),
),
Expand Down
23 changes: 21 additions & 2 deletions src/component/KnownObjects/KnownObjects.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
import { Card as LocationCard, Icon as LocationIcon } from './Location.js'
import { Card as PinnedCard, Icon as PinnedIcon } from './Pinned.js'

import { Center } from '#icons/LucideIcon.tsx'
import './KnownObjects.css'

enum TabType {
Expand Down Expand Up @@ -126,8 +127,26 @@ export const KnownObjects = (props: {
</Show>
<Show when={isActive(TabType.Location) && hasLocations}>
<For each={props.locations}>
{(location) => (
<DescribeInstance device={props.device} instance={location} />
{(geoLocation) => (
<DescribeInstance
device={props.device}
instance={geoLocation}
actions={
<button
type="button"
onClick={() => {
location.navigate({
deviceMap: {
centerLocationSource: geoLocation.Resources[6],
},
})
}}
title="Center map on location"
>
<Center strokeWidth={1} />
</button>
}
/>
)}
</For>
</Show>
Expand Down
4 changes: 4 additions & 0 deletions src/component/KnownObjects/Location.css
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,7 @@ div.device-map .maplibregl-ctrl-scale {
border-color: var(--chart-labels);
color: var(--chart-labels);
}

div.device-map .maplibregl-canvas-container {
cursor: default;
}
92 changes: 66 additions & 26 deletions src/component/KnownObjects/Location.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { useNavigation } from '#context/Navigation.tsx'
import { useParameters } from '#context/Parameters.js'
import { Center, Map, ZoomIn, ZoomOut } from '#icons/LucideIcon.js'
import { Lock, Map, Unlock, ZoomIn, ZoomOut } from '#icons/LucideIcon.js'
import { createMap } from '#map/createMap.js'
import { geoJSONPolygonFromCircle } from '#map/geoJSONPolygonFromCircle.js'
import { getLocationsBounds } from '#map/getLocationsBounds.js'
import { getLocationsBounds } from '#map/getLocationsBounds.ts'
import { glyphFonts } from '#map/glyphFonts.js'
import {
defaultLocationSourceColor,
Expand All @@ -11,7 +12,13 @@ import {
import { type Geolocation_14201 } from '@hello.nrfcloud.com/proto-map/lwm2m'
import type { Map as MapLibreGlMap } from 'maplibre-gl'
import { ScaleControl } from 'maplibre-gl'
import { createEffect, createMemo, onCleanup } from 'solid-js'
import {
createEffect,
createMemo,
createSignal,
onCleanup,
Show,
} from 'solid-js'

import './Location.css'

Expand All @@ -29,11 +36,18 @@ const byAge = (loc1: Geolocation_14201, loc2: Geolocation_14201) =>

export const Card = (props: { locations: Geolocation_14201[] }) => {
const parameters = useParameters()
const location = useNavigation()
const [locked, setLocked] = createSignal(true)

let ref!: HTMLDivElement
let map: MapLibreGlMap

const bounds = createMemo(() => getLocationsBounds(props.locations))
const centerLocation = createMemo(() =>
props.locations.find(
({ Resources }) =>
Resources[6] === location.current()?.deviceMap?.centerLocationSource,
),
)

createEffect(() => {
const mostRecent = props.locations.sort(byAge)[0]
Expand All @@ -42,7 +56,7 @@ export const Card = (props: { locations: Geolocation_14201[] }) => {

const {
Resources: { 0: lat, 1: lng },
} = mostRecent
} = centerLocation() ?? mostRecent

map = createMap(
ref,
Expand All @@ -65,18 +79,20 @@ export const Card = (props: { locations: Geolocation_14201[] }) => {
const lat = Resources[0]
const acc = Resources[3] ?? 500
const src = Resources[6]

// Data for Hexagon
const locationAreaSourceId = `center-circle-source-${src}`
map.addSource(
locationAreaSourceId,
geoJSONPolygonFromCircle([lng, lat], acc, 6, Math.PI / 2),
)
map.addSource(locationAreaSourceId, {
...geoJSONPolygonFromCircle([lng, lat], acc, 6, Math.PI / 2),
// This will ensure that the polygon is drawn even at low zoom levels
// See https://docs.mapbox.com/help/troubleshooting/working-with-large-geojson-data/#tolerance
tolerance: 0.001,
})
// Render Hexagon
map.addLayer({
id: `center-circle-layer-${src}`,
type: 'line',
source: `center-circle-source-${src}`,
layout: {},
source: locationAreaSourceId,
paint: {
'line-color':
locationSourceColors[src] ?? defaultLocationSourceColor,
Expand Down Expand Up @@ -105,16 +121,29 @@ export const Card = (props: { locations: Geolocation_14201[] }) => {
},
})
}
})

map.fitBounds(bounds(), {
padding: 20,
maxZoom: 16,
})
onCleanup(() => {
map?.remove()
})
})

onCleanup(() => {
map?.remove()
createEffect(() => {
if (centerLocation() === undefined) return
map.fitBounds(getLocationsBounds([centerLocation()!]), {
padding: 40,
maxZoom: 16,
})
})

createEffect(() => {
if (locked()) {
map.scrollZoom.disable()
map.dragPan.disable()
} else {
map.scrollZoom.enable()
map.dragPan.enable()
}
})

return (
Expand All @@ -123,17 +152,28 @@ export const Card = (props: { locations: Geolocation_14201[] }) => {
<button type="button" onClick={() => map?.zoomIn()}>
<ZoomIn />
</button>
<button
type="button"
onClick={() =>
map?.fitBounds(bounds(), {
padding: 20,
maxZoom: 16,
})
<Show
when={locked()}
fallback={
<button
type="button"
onClick={() => {
setLocked(true)
}}
>
<Lock />
</button>
}
>
<Center />
</button>
<button
type="button"
onClick={() => {
setLocked(false)
}}
>
<Unlock />
</button>
</Show>
<button type="button" onClick={() => map?.zoomOut()}>
<ZoomOut />
</button>
Expand Down
9 changes: 9 additions & 0 deletions src/component/lwm2m/DescribeInstance.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
.lwm2m.instance header {
display: flex;
justify-content: space-between;
}

.lwm2m.instance header .actions {
display: flex;
align-items: center;
}
10 changes: 8 additions & 2 deletions src/component/lwm2m/DescribeInstance.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,19 @@ import {
instanceTs,
type LwM2MObjectInstance,
} from '@hello.nrfcloud.com/proto-map/lwm2m'
import { Show } from 'solid-js'
import { Show, type JSX } from 'solid-js'
import { RelativeTime } from '../RelativeTime.js'
import { ToggleButton } from '../ToggleButton.jsx'
import { WhenToggled } from '../WhenToggled.jsx'
import { DescribeObject } from './DescribeObject.js'
import { DescribeResources } from './DescribeResources.js'

import './DescribeInstance.css'

export const DescribeInstance = (props: {
instance: LwM2MObjectInstance
device: Device
actions?: JSX.Element
}) => {
const definition = definitions[props.instance.ObjectID]
const ts = instanceTs(props.instance)
Expand Down Expand Up @@ -51,7 +54,10 @@ export const DescribeInstance = (props: {
</RelativeTime>
</small>
</h2>
<ToggleButton title="resources" id={toggleId} />
<div class="actions">
{props.actions}
<ToggleButton title="resources" id={toggleId} />
</div>
</header>
<WhenToggled id={toggleId}>
<DescribeResources
Expand Down
10 changes: 10 additions & 0 deletions src/context/navigation/decodeNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,16 @@ export const decode = (encoded?: string): Navigation | undefined => {
}
}

const deviceMapState = rest.find(
(s) => s.split(':', 2)[0] === FieldKey.DeviceMap,
)
if (deviceMapState !== undefined) {
const [, centerLocationSource] = deviceMapState.split(':', 3)
nav.deviceMap = {
centerLocationSource: centerLocationSource ?? 'none',
}
}

const helpState = rest.find((s) => s.split(':', 2)[0] === FieldKey.Tutorial)
if (helpState !== undefined) {
nav.tutorial = helpState.split(':', 2)[1] as string
Expand Down
21 changes: 21 additions & 0 deletions src/context/navigation/encodeNavigation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,27 @@ void describe('encode() / decode()', () => {
},
))

void it('should encode the device map state', () =>
assert.deepEqual(
decode(
encode({
panel: 'world',
deviceMap: {
centerLocationSource: 'GNSS',
},
}),
),
{
panel: 'world',
search: [],
pinnedResources: [],
toggled: [],
deviceMap: {
centerLocationSource: 'GNSS',
},
},
))

void it('should encode the tutorial state', () =>
assert.deepEqual(
decode(
Expand Down
22 changes: 20 additions & 2 deletions src/context/navigation/encodeNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,17 @@ export type NavigationMapState = {
center: { lat: number; lng: number }
zoom: number
}

// Encode which location source is currently centered on the map
export type DeviceMapState = {
centerLocationSource: string
}
export type Navigation = {
panel: string
search: SearchTerm[]
pinnedResources: PinnedResource[]
map?: NavigationMapState
deviceMap?: DeviceMapState
tutorial?: keyof TutorialContent
toggled: string[]
query?: URLSearchParams
Expand All @@ -27,6 +33,7 @@ export enum FieldKey {
Search = 's',
PinnedResources = 'r',
Map = 'm',
DeviceMap = 'M',
Tutorial = 'T',
Toggled = 't',
}
Expand All @@ -38,8 +45,16 @@ export const encode = (
): string | undefined => {
if (navigation === undefined) return ''
const parts = []
const { panel, search, pinnedResources, map, tutorial, toggled, query } =
navigation
const {
panel,
search,
pinnedResources,
map,
deviceMap,
tutorial,
toggled,
query,
} = navigation
let panelWithQuery = `${panel ?? ''}`
if (query !== undefined) panelWithQuery += '?' + query.toString()
parts.push(panelWithQuery)
Expand Down Expand Up @@ -69,6 +84,9 @@ export const encode = (
`${FieldKey.Map}:${map.zoom}:${map.center.lat},${map.center.lng}`,
)
}
if (deviceMap !== undefined) {
parts.push(`${FieldKey.DeviceMap}:${deviceMap.centerLocationSource}`)
}
if (tutorial !== undefined) {
parts.push(`${FieldKey.Tutorial}:${tutorial}`)
}
Expand Down
Loading

0 comments on commit e3bb11a

Please sign in to comment.