Initial working genericized map implementation

This shows dynamically adding layers and sources and actually reads from
them!
This commit is contained in:
Eli Ribble 2026-04-23 23:02:53 +00:00
parent c6282c9f5e
commit cad01e689e
No known key found for this signature in database
4 changed files with 320 additions and 8 deletions

68
ts/map/Layer.vue Normal file
View file

@ -0,0 +1,68 @@
<template>
<!-- Renderless component -->
</template>
<script setup lang="ts">
import maplibregl from "maplibre-gl";
import { inject, onMounted, onBeforeUnmount, Ref, watch } from "vue";
type LayerType = maplibregl.LayerSpecification["type"];
export interface Props {
filter?: maplibregl.FilterSpecification;
id: string;
paint: Object;
source: string;
sourceLayer: string;
type: LayerType;
}
const props = withDefaults(defineProps<Props>(), {});
type RegisterLayerFunc = (id: string, config: any) => void;
type UnregisterLayerFunc = (id: string) => void;
const map: Ref<maplibregl.Map | null> | undefined = inject("map");
const registerLayer: RegisterLayerFunc | undefined = inject("registerLayer");
const unregisterLayer: UnregisterLayerFunc | undefined =
inject("unregisterLayer");
const getLayerConfig = (): maplibregl.LayerSpecification => {
let result: maplibregl.LayerSpecification = {
id: props.id,
source: props.source,
"source-layer": props.sourceLayer,
type: props.type,
...(props.filter && { filter: props.filter }),
...(props.paint && { paint: props.paint }),
} as maplibregl.LayerSpecification;
return result;
};
onMounted(() => {
if (registerLayer) {
registerLayer(props.id, getLayerConfig());
} else {
console.log("registerLayer is nully");
}
});
onBeforeUnmount(() => {
if (unregisterLayer) {
unregisterLayer(props.id);
} else {
console.log("unregisterLayer is nully");
}
});
// Update paint/layout properties reactively
watch(
() => props.paint,
(newPaint) => {
if (map && map.value?.getLayer(props.id)) {
Object.entries(newPaint || {}).forEach(([key, value]) => {
if (!(map && map.value)) return;
map.value.setPaintProperty(props.id, key, value);
});
}
},
{ deep: true },
);
</script>

125
ts/map/Map.vue Normal file
View file

@ -0,0 +1,125 @@
<style scoped>
.map {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
height: 100%;
width: 100%;
}
</style>
<template>
<div ref="mapDiv" class="map" v-bind="$attrs"></div>
<slot />
</template>
<script setup lang="ts">
import "maplibre-gl/dist/maplibre-gl.css";
import maplibregl from "maplibre-gl";
import {
onBeforeUnmount,
onMounted,
provide,
ref,
type Ref,
shallowRef,
} from "vue";
import type { Bounds } from "@/type/api";
interface Props {
bounds?: Bounds;
center?: maplibregl.LngLatLike;
zoom?: number;
}
const props = withDefaults(defineProps<Props>(), {});
const mapDiv = ref<HTMLElement | null>(null);
const map: Ref<maplibregl.Map | null> = shallowRef(null);
// Provide the map instance to children
provide("map", map);
// Registry for tracking child components
const sources = new Map();
const layers = new Map();
provide("registerSource", (id: string, config: any) => {
console.log("register source", id, config);
sources.set(id, config);
if (map.value && map.value.loaded()) {
if (!map.value.getSource(id)) {
map.value.addSource(id, config);
}
}
});
provide("unregisterSource", (id: string) => {
console.log("unregister source", id);
sources.delete(id);
if (map.value && map.value?.getSource(id)) {
map.value.removeSource(id);
}
});
provide("registerLayer", (id: string, config: any) => {
console.log("register layer", id, config);
layers.set(id, config);
if (map.value && map.value.loaded()) {
if (!map.value.getLayer(id)) {
map.value.addLayer(config);
}
}
});
provide("unregisterLayer", (id: string) => {
console.log("unregister layer", id);
layers.delete(id);
if (map.value?.getLayer(id)) {
map.value.removeLayer(id);
}
});
function initializeMap() {
if (!mapDiv.value) return;
console.log("initializing map...");
const _map = new maplibregl.Map({
container: mapDiv.value,
center: props.center,
style: "https://tiles.stadiamaps.com/styles/alidade_smooth.json",
zoom: props.zoom,
});
// When map loads, add all registered sources/layers
_map.on("load", () => {
console.log("map loaded.");
sources.forEach((config, id) => {
console.log("adding source", id, config);
if (!_map.getSource(id)) {
_map.addSource(id, config);
}
});
layers.forEach((config, id) => {
console.log("adding layer", id, config);
if (!_map.getLayer(id)) {
_map.addLayer(config);
}
});
});
map.value = _map;
}
onMounted(() => {
initializeMap();
});
onBeforeUnmount(() => {
if (map.value) {
map.value.remove();
}
});
</script>

46
ts/map/Source.vue Normal file
View file

@ -0,0 +1,46 @@
<template>
<!-- Renderless component -->
</template>
<script setup>
import { inject, onMounted, onBeforeUnmount, watch } from "vue";
const props = defineProps({
id: { type: String, required: true },
type: { type: String, required: true },
tiles: Array,
url: String,
// ... other source properties
});
const map = inject("map");
const registerSource = inject("registerSource");
const unregisterSource = inject("unregisterSource");
const getSourceConfig = () => {
const { id, ...config } = props;
return config;
};
onMounted(() => {
registerSource(props.id, getSourceConfig());
});
onBeforeUnmount(() => {
unregisterSource(props.id);
});
// Watch for prop changes and update source
watch(
() => getSourceConfig(),
(newConfig) => {
if (map.value?.getSource(props.id)) {
// MapLibre doesn't support updating sources directly
// You'd need to remove and re-add, or handle specific updates
unregisterSource(props.id);
registerSource(props.id, newConfig);
}
},
{ deep: true },
);
</script>

View file

@ -1,3 +1,13 @@
<style scoped>
.map-container {
background-color: #e9ecef;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
height: 500px;
margin-top: 20px;
position: relative;
}
</style>
<template>
<!-- Dashboard Header -->
<div class="row mb-4">
@ -121,13 +131,74 @@
<h3 class="section-title">Mosquito Activity Heatmap</h3>
<div class="row">
<div class="col-12">
<MapAggregate
:bounds="mapBounds()"
@cell-click="doClickMap"
:markers="[]"
:organizationId="session.organization?.id ?? 1"
:tegola="session.urls?.tegola ?? ''"
/>
<div class="map-container">
<Map
:bounds="mapBounds()"
@cell-click="doClickMap"
class="map"
:markers="[]"
:organizationId="session.organization?.id ?? 1"
:tegola="session.urls?.tegola ?? ''"
>
<Layer
id="mosquito_source"
:filter="[
'==',
['zoom'],
['+', 2, ['to-number', ['get', 'resolution']]],
]"
:paint="{ 'fill-opacity': 0.4, 'fill-color': '#dc3545' }"
source="tegola"
sourceLayer="mosquito_source"
,
type="fill"
/>
<Layer
id="service_request"
:filter="[
'==',
['zoom'],
['+', 2, ['to-number', ['get', 'resolution']]],
]"
:paint="{ 'fill-opacity': 0.4, 'fill-color': '#ffc107' }"
source="tegola"
sourceLayer="service_request"
,
type="fill"
/>
<Layer
id="trap"
:filter="[
'==',
['zoom'],
['+', 2, ['to-number', ['get', 'resolution']]],
]"
:paint="{ 'fill-opacity': 0.4, 'fill-color': '#ffc107' }"
source="tegola"
sourceLayer="trap"
,
type="fill"
/>
<Layer
id="service-area"
:paint="{ 'line-color': '#f00' }"
source="tegola"
sourceLayer="service-area-bounds"
,
type="line"
/>
<Source
id="tegola"
type="vector"
:tiles="[
session.urls?.tegola +
'maps/nidus/{z}/{x}/{y}?id=' +
session.organization?.id,
]"
/>
</Map>
</div>
</div>
</div>
@ -158,7 +229,9 @@
<script setup lang="ts">
import { onMounted, reactive } from "vue";
import MapAggregate from "@/components/MapAggregate.vue";
import Map from "@/map/Map.vue";
import Layer from "@/map/Layer.vue";
import Source from "@/map/Source.vue";
import { formatBigNumber, formatTimeRelative } from "@/format";
import { router } from "@/route/config";
import { useSessionStore } from "@/store/session";