Initial working genericized map implementation
This shows dynamically adding layers and sources and actually reads from them!
This commit is contained in:
parent
c6282c9f5e
commit
cad01e689e
4 changed files with 320 additions and 8 deletions
68
ts/map/Layer.vue
Normal file
68
ts/map/Layer.vue
Normal 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
125
ts/map/Map.vue
Normal 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
46
ts/map/Source.vue
Normal 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>
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue