370 lines
10 KiB
Vue
370 lines
10 KiB
Vue
<script lang="ts" setup>
|
|
import { Map } from "@/components/ui/map";
|
|
import {
|
|
Preview,
|
|
SectionTitle,
|
|
SectionContent,
|
|
InstallPackage,
|
|
PreviewCode,
|
|
} from "@/components/docs";
|
|
const markersData = {
|
|
type: "FeatureCollection" as const,
|
|
features: [
|
|
{
|
|
type: "Feature" as const,
|
|
properties: { name: "Cafe Berlin", type: "cafe" },
|
|
geometry: {
|
|
type: "Point" as const,
|
|
coordinates: [13.388, 52.517],
|
|
},
|
|
},
|
|
{
|
|
type: "Feature" as const,
|
|
properties: { name: "Restaurant Alex", type: "restaurant" },
|
|
geometry: {
|
|
type: "Point" as const,
|
|
coordinates: [13.39, 52.518],
|
|
},
|
|
},
|
|
{
|
|
type: "Feature" as const,
|
|
properties: { name: "Coffee House", type: "cafe" },
|
|
geometry: {
|
|
type: "Point" as const,
|
|
coordinates: [13.385, 52.515],
|
|
},
|
|
},
|
|
{
|
|
type: "Feature" as const,
|
|
properties: { name: "Pizza Place", type: "restaurant" },
|
|
geometry: {
|
|
type: "Point" as const,
|
|
coordinates: [13.392, 52.519],
|
|
},
|
|
},
|
|
{
|
|
type: "Feature" as const,
|
|
properties: { name: "Burger Joint", type: "restaurant" },
|
|
geometry: {
|
|
type: "Point" as const,
|
|
coordinates: [13.387, 52.516],
|
|
},
|
|
},
|
|
{
|
|
type: "Feature" as const,
|
|
properties: { name: "Starbucks", type: "cafe" },
|
|
geometry: {
|
|
type: "Point" as const,
|
|
coordinates: [13.391, 52.52],
|
|
},
|
|
},
|
|
{
|
|
type: "Feature" as const,
|
|
properties: { name: "Sushi Bar", type: "restaurant" },
|
|
geometry: {
|
|
type: "Point" as const,
|
|
coordinates: [13.395, 52.522],
|
|
},
|
|
},
|
|
{
|
|
type: "Feature" as const,
|
|
properties: { name: "Bakery", type: "cafe" },
|
|
geometry: {
|
|
type: "Point" as const,
|
|
coordinates: [13.383, 52.514],
|
|
},
|
|
},
|
|
{
|
|
type: "Feature" as const,
|
|
properties: { name: "Italian Restaurant", type: "restaurant" },
|
|
geometry: {
|
|
type: "Point" as const,
|
|
coordinates: [13.398, 52.525],
|
|
},
|
|
},
|
|
{
|
|
type: "Feature" as const,
|
|
properties: { name: "Tea House", type: "cafe" },
|
|
geometry: {
|
|
type: "Point" as const,
|
|
coordinates: [13.38, 52.512],
|
|
},
|
|
},
|
|
],
|
|
};
|
|
</script>
|
|
|
|
<template>
|
|
<div id="preview" class="-mt-20">
|
|
<Preview>
|
|
<template #preview>
|
|
<Map class="w-full h-96" :center="[13.388, 52.517]" :zoom="9.5" :markers="markersData" />
|
|
</template>
|
|
<template #code>
|
|
<PreviewCode>
|
|
{{ `
|
|
<Map class="w-full h-96" :center="[13.388, 52.517]" :zoom="9.5" :markers="markersData" />
|
|
` }}
|
|
</PreviewCode>
|
|
</template>
|
|
</Preview>
|
|
</div>
|
|
<div id="installation">
|
|
<SectionTitle>Installation</SectionTitle>
|
|
<SectionContent>Install the following dependencies:</SectionContent>
|
|
<InstallPackage>add maplibre-gl</InstallPackage>
|
|
<SectionContent>
|
|
Copy and paste the following code into your project.
|
|
</SectionContent>
|
|
<PreviewCode title="components/ui/map/Map.vue">
|
|
{{`
|
|
<${""}script setup lang="ts">
|
|
import "maplibre-gl/dist/maplibre-gl.css";
|
|
import maplibregl, { type MapOptions } from "maplibre-gl";
|
|
import { ref, onMounted } from "vue";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Plus, Minus, MapPin, Compass, Expand, X } from "@lucide/vue";
|
|
import type { FeatureCollection, Point } from "geojson";
|
|
import { cn } from "@mykopkb/core/utils/cn";
|
|
import { map } from "@mykopkb/core/styles/map.styles";
|
|
|
|
const mapRef = ref();
|
|
const mapInstance = ref<maplibregl.Map>();
|
|
const isFullscreen = ref(false);
|
|
|
|
const {
|
|
class: className,
|
|
markers,
|
|
...props
|
|
} = defineProps<
|
|
Partial<MapOptions> & {
|
|
class?: string;
|
|
markers?: FeatureCollection<Point>;
|
|
}
|
|
>();
|
|
|
|
onMounted(() => {
|
|
mapInstance.value = new maplibregl.Map({
|
|
...Object.fromEntries(Object.entries(props).filter(([_, value]) => value)),
|
|
style: "https://tiles.openfreemap.org/styles/positron",
|
|
container: mapRef.value,
|
|
});
|
|
|
|
markers &&
|
|
mapInstance.value.on("load", () => {
|
|
// Add source with clustering
|
|
mapInstance.value!.addSource("markers", {
|
|
type: "geojson",
|
|
data: markers,
|
|
cluster: true,
|
|
clusterMaxZoom: 14, // Max zoom for clustering
|
|
clusterRadius: 50, // Cluster radius in pixels
|
|
});
|
|
|
|
// Layer for clusters (circles)
|
|
mapInstance.value!.addLayer({
|
|
id: "clusters",
|
|
type: "circle",
|
|
source: "markers",
|
|
filter: ["has", "point_count"],
|
|
paint: {
|
|
"circle-color": [
|
|
"step",
|
|
["get", "point_count"],
|
|
"#cccccc", // Color for < 5 points
|
|
5,
|
|
"#cccccc", // Color for 5-10 points
|
|
10,
|
|
"#cccccc", // Color for > 10 points
|
|
],
|
|
"circle-radius": [
|
|
"step",
|
|
["get", "point_count"],
|
|
20, // Radius for < 5 points
|
|
5,
|
|
30, // Radius for 5-10 points
|
|
10,
|
|
40, // Radius for > 10 points
|
|
],
|
|
},
|
|
});
|
|
|
|
// Layer for cluster count numbers
|
|
mapInstance.value!.addLayer({
|
|
id: "cluster-count",
|
|
type: "symbol",
|
|
source: "markers",
|
|
filter: ["has", "point_count"],
|
|
layout: {
|
|
"text-field": "{point_count_abbreviated}",
|
|
"text-font": ["Open Sans Bold"],
|
|
"text-size": 12,
|
|
},
|
|
});
|
|
|
|
// Layer for individual points (unclustered)
|
|
mapInstance.value!.addLayer({
|
|
id: "unclustered-point",
|
|
type: "circle",
|
|
source: "markers",
|
|
filter: ["!", ["has", "point_count"]],
|
|
paint: {
|
|
"circle-color": "#333333",
|
|
"circle-radius": 8,
|
|
"circle-stroke-width": 2,
|
|
"circle-stroke-color": "#ffffff",
|
|
},
|
|
});
|
|
|
|
// Click on cluster to zoom in
|
|
mapInstance.value!.on("click", "clusters", async (e) => {
|
|
const features = mapInstance.value!.queryRenderedFeatures(e.point, {
|
|
layers: ["clusters"],
|
|
});
|
|
|
|
const clusterId = features[0]?.properties.cluster_id;
|
|
const source = mapInstance.value!.getSource(
|
|
"markers"
|
|
) as maplibregl.GeoJSONSource;
|
|
|
|
try {
|
|
const zoom = await source.getClusterExpansionZoom(clusterId);
|
|
|
|
mapInstance.value!.easeTo({
|
|
center: (features[0]?.geometry as any).coordinates,
|
|
zoom: zoom,
|
|
});
|
|
} catch (err) {
|
|
console.error("Error getting cluster expansion zoom:", err);
|
|
}
|
|
});
|
|
|
|
// Click on individual point to show popup
|
|
mapInstance.value!.on("click", "unclustered-point", (e) => {
|
|
const coordinates = (
|
|
e.features![0]?.geometry as any
|
|
).coordinates.slice();
|
|
const { name, type } = (e.features![0]?.properties || {}) as any;
|
|
|
|
new maplibregl.Popup()
|
|
.setLngLat(coordinates)
|
|
.setHTML(\`<h3>\${name}</h3><p>\${type}</p>\`)
|
|
.addTo(mapInstance.value!);
|
|
});
|
|
|
|
// Change cursor on hover cluster/point
|
|
mapInstance.value!.on("mouseenter", "clusters", () => {
|
|
mapInstance.value!.getCanvas().style.cursor = "pointer";
|
|
});
|
|
mapInstance.value!.on("mouseleave", "clusters", () => {
|
|
mapInstance.value!.getCanvas().style.cursor = "";
|
|
});
|
|
mapInstance.value!.on("mouseenter", "unclustered-point", () => {
|
|
mapInstance.value!.getCanvas().style.cursor = "pointer";
|
|
});
|
|
mapInstance.value!.on("mouseleave", "unclustered-point", () => {
|
|
mapInstance.value!.getCanvas().style.cursor = "";
|
|
});
|
|
});
|
|
});
|
|
|
|
const zoomIn = () => {
|
|
mapInstance.value?.zoomIn();
|
|
};
|
|
|
|
const zoomOut = () => {
|
|
mapInstance.value?.zoomOut();
|
|
};
|
|
|
|
const resetNorth = () => {
|
|
mapInstance.value?.easeTo({ bearing: 0, pitch: 0 });
|
|
};
|
|
|
|
const locateMe = () => {
|
|
if (!navigator.geolocation) {
|
|
alert("Geolocation is not supported by your browser");
|
|
return;
|
|
}
|
|
|
|
navigator.geolocation.getCurrentPosition(
|
|
(position) => {
|
|
const { longitude, latitude } = position.coords;
|
|
mapInstance.value?.flyTo({
|
|
center: [longitude, latitude],
|
|
zoom: 14,
|
|
});
|
|
|
|
new maplibregl.Marker({ color: "var(--color-foreground)" })
|
|
.setLngLat([longitude, latitude])
|
|
.addTo(mapInstance.value!);
|
|
},
|
|
(error) => {
|
|
alert("Failed to get location: " + error.message);
|
|
}
|
|
);
|
|
};
|
|
|
|
const toggleFullscreen = () => {
|
|
if (!mapRef.value) return;
|
|
|
|
if (!isFullscreen.value) {
|
|
if (mapRef.value.requestFullscreen) {
|
|
mapRef.value.requestFullscreen();
|
|
}
|
|
} else {
|
|
if (document.exitFullscreen) {
|
|
document.exitFullscreen();
|
|
}
|
|
}
|
|
isFullscreen.value = !isFullscreen.value;
|
|
};
|
|
</${""}script>
|
|
|
|
<template>
|
|
<div data-scope="map" data-part="root" ref="mapRef" :class="cn(map, className)">
|
|
<div data-scope="map" data-part="controls">
|
|
<Button data-scope="map" data-part="zoom-in" @click="zoomIn" variant="ghost" size="sm">
|
|
<Plus />
|
|
</Button>
|
|
<Button data-scope="map" data-part="zoom-out" @click="zoomOut" variant="ghost" size="sm">
|
|
<Minus />
|
|
</Button>
|
|
<Button data-scope="map" data-part="reset-north" @click="resetNorth" variant="ghost" size="sm">
|
|
<Compass />
|
|
</Button>
|
|
<Button data-scope="map" data-part="locate" @click="locateMe" variant="ghost" size="sm">
|
|
<MapPin />
|
|
</Button>
|
|
<Button data-scope="map" data-part="toggle-fullscreen" @click="toggleFullscreen" variant="ghost" size="sm">
|
|
<Expand v-if="!isFullscreen" />
|
|
<X v-else />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
` }}
|
|
</PreviewCode>
|
|
<PreviewCode title="components/ui/map/index.ts">
|
|
{{ `
|
|
export { default as Map } from "./Map.vue";
|
|
` }}
|
|
</PreviewCode>
|
|
<SectionContent>
|
|
Update the import paths to match your project setup.
|
|
</SectionContent>
|
|
</div>
|
|
<div id="usage">
|
|
<SectionTitle>Usage</SectionTitle>
|
|
<PreviewCode>
|
|
{{ `
|
|
import { Map } from "@/components/ui/map";
|
|
` }}
|
|
</PreviewCode>
|
|
<PreviewCode>
|
|
{{ `
|
|
<Map class="w-full h-96" :center="[13.388, 52.517]" :zoom="9.5" :markers="markersData" />
|
|
` }}
|
|
</PreviewCode>
|
|
</div>
|
|
</template>
|