Add Map and Filter

This commit is contained in:
2024-05-26 22:52:39 +02:00
parent 7b344d4fa4
commit aae9215215
18 changed files with 45920 additions and 42 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
.vscode
src/data
+42
View File
@@ -19,14 +19,17 @@
"devDependencies": {
"@rushstack/eslint-patch": "^1.8.0",
"@tsconfig/node20": "^20.1.4",
"@types/leaflet": "^1.9.12",
"@types/node": "^20.12.5",
"@vitejs/plugin-vue": "^5.0.4",
"@vue-leaflet/vue-leaflet": "^0.10.1",
"@vue/eslint-config-prettier": "^9.0.0",
"@vue/eslint-config-typescript": "^13.0.0",
"@vue/tsconfig": "^0.5.1",
"autoprefixer": "^10.4.19",
"eslint": "^8.57.0",
"eslint-plugin-vue": "^9.23.0",
"leaflet": "^1.9.4",
"npm-run-all2": "^6.1.2",
"postcss": "^8.4.38",
"prettier": "^3.2.5",
@@ -1021,12 +1024,27 @@
"integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==",
"dev": true
},
"node_modules/@types/geojson": {
"version": "7946.0.14",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.14.tgz",
"integrity": "sha512-WCfD5Ht3ZesJUsONdhvm84dmzWOiOzOAqOncN0++w0lBw1o8OuDNJF2McvvCef/yBqb/HYRahp1BYtODFQ8bRg==",
"dev": true
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"dev": true
},
"node_modules/@types/leaflet": {
"version": "1.9.12",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.12.tgz",
"integrity": "sha512-BK7XS+NyRI291HIo0HCfE18Lp8oA30H1gpi1tf0mF3TgiCEzanQjOqNZ4x126SXzzi2oNSZhZ5axJp1k0iM6jg==",
"dev": true,
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/lodash": {
"version": "4.17.0",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.0.tgz",
@@ -1284,6 +1302,24 @@
"path-browserify": "^1.0.1"
}
},
"node_modules/@vue-leaflet/vue-leaflet": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@vue-leaflet/vue-leaflet/-/vue-leaflet-0.10.1.tgz",
"integrity": "sha512-RNEDk8TbnwrJl8ujdbKgZRFygLCxd0aBcWLQ05q/pGv4+d0jamE3KXQgQBqGAteE1mbQsk3xoNcqqUgaIGfWVg==",
"dev": true,
"dependencies": {
"vue": "^3.2.25"
},
"peerDependencies": {
"@types/leaflet": "^1.5.7",
"leaflet": "^1.6.0"
},
"peerDependenciesMeta": {
"@types/leaflet": {
"optional": true
}
}
},
"node_modules/@vue/compiler-core": {
"version": "3.4.26",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.4.26.tgz",
@@ -2701,6 +2737,12 @@
"json-buffer": "3.0.1"
}
},
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"dev": true
},
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+3
View File
@@ -25,14 +25,17 @@
"devDependencies": {
"@rushstack/eslint-patch": "^1.8.0",
"@tsconfig/node20": "^20.1.4",
"@types/leaflet": "^1.9.12",
"@types/node": "^20.12.5",
"@vitejs/plugin-vue": "^5.0.4",
"@vue-leaflet/vue-leaflet": "^0.10.1",
"@vue/eslint-config-prettier": "^9.0.0",
"@vue/eslint-config-typescript": "^13.0.0",
"@vue/tsconfig": "^0.5.1",
"autoprefixer": "^10.4.19",
"eslint": "^8.57.0",
"eslint-plugin-vue": "^9.23.0",
"leaflet": "^1.9.4",
"npm-run-all2": "^6.1.2",
"postcss": "^8.4.38",
"prettier": "^3.2.5",
+43 -14
View File
@@ -5,6 +5,7 @@ import { computed, ref } from 'vue';
import { useThemeStore } from '@/stores/theme';
import { useMenuStore } from '@/stores/menu';
import { SUPPORT_LOCALES } from '@/i18n';
import { useMapStore } from '@/stores/map';
const theme = useThemeStore();
const router = useRouter();
@@ -17,6 +18,7 @@ const activeRoute = computed({
}
});
const menuStore = useMenuStore();
const mapStore = useMapStore();
const locales = ref(SUPPORT_LOCALES);
</script>
@@ -37,35 +39,55 @@ const locales = ref(SUPPORT_LOCALES);
:icon="menuStore.items[0].icon"
:rightIcon="menuStore.items[0].rightIcon"
@selected="() => menuStore.click(0)"
>{{ menuStore.items[0].text }}</VaMenuItem
>{{
$te(menuStore.items[0].text)
? $t(menuStore.items[0].text)
: menuStore.items[0].text
}}</VaMenuItem
>
<VaMenuItem
v-if="menuStore.items.length > 1"
:icon="menuStore.items[1].icon"
:rightIcon="menuStore.items[1].rightIcon"
@selected="() => menuStore.click(1)"
>{{ menuStore.items[1].text }}</VaMenuItem
>{{
$te(menuStore.items[1].text)
? $t(menuStore.items[1].text)
: menuStore.items[1].text
}}</VaMenuItem
>
<VaMenuItem
v-if="menuStore.items.length > 2"
:icon="menuStore.items[2].icon"
:rightIcon="menuStore.items[2].rightIcon"
@selected="() => menuStore.click(2)"
>{{ menuStore.items[2].text }}</VaMenuItem
>{{
$te(menuStore.items[2].text)
? $t(menuStore.items[2].text)
: menuStore.items[2].text
}}</VaMenuItem
>
<VaMenuItem
v-if="menuStore.items.length > 3"
:icon="menuStore.items[3].icon"
:rightIcon="menuStore.items[3].rightIcon"
@selected="() => menuStore.click(3)"
>{{ menuStore.items[3].text }}</VaMenuItem
>{{
$te(menuStore.items[3].text)
? $t(menuStore.items[3].text)
: menuStore.items[3].text
}}</VaMenuItem
>
<VaMenuItem
v-if="menuStore.items.length > 4"
:icon="menuStore.items[4].icon"
:rightIcon="menuStore.items[4].rightIcon"
@selected="() => menuStore.click(4)"
>{{ menuStore.items[4].text }}</VaMenuItem
>{{
$te(menuStore.items[4].text)
? $t(menuStore.items[4].text)
: menuStore.items[4].text
}}</VaMenuItem
>
<VaDivider />
<VaMenuItem>
@@ -73,24 +95,27 @@ const locales = ref(SUPPORT_LOCALES);
v-model="theme.mode"
true-value="dark"
false-value="light"
label="Toggle dark mode"
:label="$t('toggle_dark_mode')"
size="small"
/></VaMenuItem>
<VaMenuItem
><VaSelect
class="w-full"
v-model="theme.lang"
label="Language"
:label="$t('i18n.language')"
:options="locales"
value-by="code"
track-by="code"
:text-by="(l) => $t(`locales.${(l as any)?.code || 'en'}`)"
placeholder="Select an option"
:text-by="(l) => $t(`i18n.locales.${(l as any)?.code || 'en'}`)"
:placeholder="$t('i18n.select')"
/></VaMenuItem>
</VaMenu>
</template>
<template #left>
<VaNavbarItem class="text-lg font-bold"> LOGO </VaNavbarItem>
<VaNavbarItem class="text-lg font-bold">OSM Filter</VaNavbarItem>
<VaNavbarItem v-if="mapStore.loading"
><VaIcon name="sync" spin="counter-clockwise"
/></VaNavbarItem>
</template>
</VaNavbar>
</template>
@@ -103,10 +128,14 @@ const locales = ref(SUPPORT_LOCALES);
v-model="activeRoute"
>
<template #tabs>
<VaTab name="/"><VaIcon name="house" />Home</VaTab>
<VaTab name="/map"><VaIcon name="house" />Home</VaTab>
<VaTab name="/filter"><VaIcon name="house" />filter</VaTab>
<VaTab name="/about"><VaIcon name="house" />Home</VaTab>
<VaTab name="/"><VaIcon name="house" />{{ $t('tabs.home') }}</VaTab>
<VaTab name="/map"><VaIcon name="map" />{{ $t('tabs.map') }}</VaTab>
<VaTab name="/filter"
><VaIcon name="filter_alt" />{{ $t('tabs.filter') }}</VaTab
>
<VaTab name="/about"
><VaIcon name="info" />{{ $t('tabs.about') }}</VaTab
>
</template>
</VaTabs>
</template>
+4
View File
@@ -9,3 +9,7 @@ body {
background-color: var(--va-background-primary);
color: var(--va-text-primary);
}
.text-content {
@apply sm:mx-auto sm:w-2/3 md:w-1/2;
}
File diff suppressed because it is too large Load Diff
+77
View File
@@ -0,0 +1,77 @@
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'path';
import { writeFile } from 'node:fs/promises';
type OSMAmenity = {
value: string;
in_wiki: boolean;
lang: Record<string, string>;
};
const url = (lang: string, page: number, perPage = 999) =>
`https://taginfo.openstreetmap.org/api/4/key/values?key=amenity&filter=all&lang=${lang}&sortname=count&sortorder=desc&rp=${perPage}&page=${page}`;
async function getPage(lang: string, page: number, perPage = 999) {
return fetch(url(lang, page, perPage))
.then<TagInfoApi<KeyValues>>((b) => b.json() as any)
.then((r) => r.data);
}
export interface TagInfoApi<E> {
url: string;
page: number;
rp: number;
total: number;
data: E[];
}
export interface KeyValues {
value: string;
count: number;
fraction: number;
in_wiki: boolean;
description: string;
desclang: string;
descdir: 'ltr' | 'rtl';
}
const { total } = await fetch(url('en', 1, 1)).then<TagInfoApi<KeyValues>>(
(b) => b.json() as any
);
console.log(total);
const all: OSMAmenity[] = await Promise.all(
new Array(Math.ceil(total / 999))
.fill(null)
.map((_, i) => getPage('en', i + 1))
).then((r) =>
r.flatMap((k) =>
k.map((kv) => ({
value: kv.value,
in_wiki: kv.in_wiki,
lang: { [kv.desclang]: kv.description }
}))
)
);
await Promise.all(
new Array(Math.ceil(total / 999))
.fill(null)
.map((_, i) => getPage('de', i + 1))
)
.then((r) => r.flat())
.then((r) =>
r.forEach(
(a) =>
(all.find((al) => al.value === a.value)!!.lang[a.desclang] =
a.description)
)
);
console.log(all);
const modulePath = dirname(fileURLToPath(import.meta.url));
await writeFile(
resolve(modulePath, '../data/amenities.json'),
JSON.stringify(all, undefined, 2),
{
flag: 'w+'
}
);
+27 -3
View File
@@ -1,7 +1,31 @@
{
"text": "Hallo",
"locales": {
"en": "Englisch",
"de": "Deutsch"
"map": {
"attribution": "Kartendaten &copy; {a} Mitwirkende"
},
"tabs": {
"home": "Start",
"map": "Karte",
"filter": "Filter",
"about": "Über"
},
"pages": {
"map": {},
"filter": {
"title": "Filter"
},
"about": {
"title": "Über uns"
}
},
"toggle_dark_mode": "Darkmode umschalten",
"i18n": {
"language": "Sprache",
"select": "Wähle eine Sprache aus",
"locales": {
"en": "Englisch",
"de": "Deutsch"
}
}
}
+26 -3
View File
@@ -1,7 +1,30 @@
{
"text": "Hello",
"locales": {
"en": "English",
"de": "German"
"map": {
"attribution": "Map data &copy; {a} contributors"
},
"tabs": {
"home": "Home",
"map": "Map",
"filter": "Filter",
"about": "About"
},
"pages": {
"map": {},
"filter": {
"title": "Filter"
},
"about": {
"title": "About us"
}
},
"toggle_dark_mode": "Toggle dark mode",
"i18n": {
"language": "Language",
"select": "Select a language",
"locales": {
"en": "English",
"de": "German"
}
}
}
+75
View File
@@ -0,0 +1,75 @@
import _amenities from './data/amenities.json';
export type OSMAmenity = {
value: string;
in_wiki: boolean;
lang: {
en?: string;
de?: string;
};
};
export const amenities: OSMAmenity[] = _amenities;
export interface APIElement {
id: string;
tags: Record<string, string>;
}
//
// Overpass
//
export interface NodeElement extends APIElement {
type: 'node';
lat: number;
lon: number;
}
export interface WayElement extends APIElement {
type: 'way';
bounds: {
minlat: number;
minlon: number;
maxlat: number;
maxlon: number;
};
nodes: number[];
geometry: { lat: number; lon: number }[];
}
export interface RelationElement extends APIElement {
type: 'relation';
bounds: {
minlat: number;
minlon: number;
maxlat: number;
maxlon: number;
};
members: (Omit<
NodeElement | WayElement | Omit<RelationElement, 'members'>,
'id' | 'tags' | 'bounds'
> & { role: string })[];
}
export type NWRElement = Readonly<NodeElement | WayElement | RelationElement>;
export function isWay(e: { type: NWRElement['type'] }): e is WayElement {
return e.type === 'way';
}
export function isNode(e: { type: NWRElement['type'] }): e is NodeElement {
return e.type === 'node';
}
export function isRelation(e: {
type: NWRElement['type'];
}): e is RelationElement {
return e.type === 'relation';
}
export function tagsToHTML(tags: APIElement['tags']) {
const entries = Object.entries(tags);
const keyValues = entries.map(
([k, v]) => `<span class="font-bold">${k}:</span><span class="">${v}</span>`
);
return `<div class="grid grid-cols-[max-content_1fr] gap-2">${keyValues.join('')}</div>`;
}
+10 -3
View File
@@ -12,10 +12,17 @@ const router = createRouter({
{
path: '/about',
name: 'about',
// route level code-splitting
// this generates a separate chunk (About.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import('../views/AboutView.vue')
},
{
path: '/map',
name: 'map',
component: () => import('../views/MapView.vue')
},
{
path: '/filter',
name: 'filter',
component: () => import('../views/FilterView.vue')
}
]
});
+102
View File
@@ -0,0 +1,102 @@
import { acceptHMRUpdate, defineStore } from 'pinia';
import { computed, reactive, readonly, ref, watch } from 'vue';
import type { LatLngBounds } from 'leaflet';
import {
isNode,
isRelation,
isWay,
type NodeElement,
type NWRElement,
type RelationElement,
type WayElement
} from '@/osm';
export const useMapStore = defineStore(
'map',
() => {
// defaults to Germany
const center = ref<[number, number]>([52.07838, 10.250244]);
const zoom = ref(6);
const bounds = ref<LatLngBounds>();
const filterForm = reactive({
amenitySelect: [] as string[]
});
const opQuery = computed(() => {
const b = bounds.value;
if (!b) {
return null;
}
const nodes = filterForm.amenitySelect
.map((a) => `nwr["amenity"="${a}"];`)
.join('\n');
return `[out:json][timeout:25][bbox:${b.getSouth()},${b.getWest()},${b.getNorth()},${b.getEast()}];
(
${nodes}
);
out geom;`;
});
const opLoading = ref(false);
const nodes = ref<NodeElement[]>([]);
const relations = ref<RelationElement[]>([]);
const ways = ref<WayElement[]>([]);
watch(
opQuery,
async (q) => {
if (!q || opLoading.value || zoom.value < 12) {
return;
}
opLoading.value = true;
try {
const elements = await fetch(
'https://overpass-api.de/api/interpreter',
{
headers: {
'content-type':
'application/x-www-form-urlencoded; charset=UTF-8'
},
body: 'data=' + encodeURIComponent(q),
method: 'POST'
}
)
.then((b) => b.json())
.catch(() => ({ elements: [] }))
.then((r) => r.elements as NWRElement[]);
nodes.value = elements.filter(isNode);
relations.value = elements.filter(isRelation);
ways.value = elements.filter(isWay);
} catch (e) {
console.error(e);
} finally {
opLoading.value = false;
}
},
{ immediate: true }
);
return {
center,
zoom,
bounds,
filterForm,
opQuery,
loading: readonly(opLoading),
nodes,
relations,
ways
};
},
{
persist: {
paths: ['center', 'zoom', 'filterForm']
}
}
);
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useMapStore, import.meta.hot));
}
+2 -5
View File
@@ -11,25 +11,22 @@ export const useThemeStore = defineStore(
const h = document.querySelector('html');
watch(mode, (newMode, oldMode) => {
applyPreset(newMode);
console.log(newMode);
if (h?.classList.replace(oldMode, newMode) === false) {
h?.classList.add(newMode);
}
});
console.log(navigator.language);
const lang = ref(
SUPPORT_LOCALES.find((l) =>
navigator.language.toLowerCase().startsWith(l.code)
)?.code || SUPPORT_LOCALES[0].code
);
watch(lang, (l) => {
console.log(l);
i18n.global.locale.value = l as any;
document.querySelector('html')?.setAttribute('lang', l);
});
return { mode, lang };
const fullName = ref(true);
return { mode, lang, fullName };
},
{
persist: {
+4 -12
View File
@@ -1,15 +1,7 @@
<template>
<div class="about">
<h1>This is an about page</h1>
</div>
<main class="text-content">
<h1 class="mt-2 text-center text-3xl">{{ $t('pages.about.title') }}</h1>
</main>
</template>
<style>
@media (min-width: 1024px) {
.about {
min-height: 100vh;
display: flex;
align-items: center;
}
}
</style>
<style></style>
+84
View File
@@ -0,0 +1,84 @@
<script setup lang="ts">
import { useForm } from 'vuestic-ui';
import { amenities } from '@/osm';
import { useMapStore } from '@/stores/map';
import { useThemeStore } from '@/stores/theme';
import { useI18n } from 'vue-i18n';
const { isValid } = useForm('formRef');
const theme = useThemeStore();
const mapStore = useMapStore();
const i18n = useI18n();
</script>
<template>
<main class="text-content">
<h1 class="mt-2 text-center text-3xl">
<VaIcon name="filter_alt" size="large" />{{ $t('pages.filter.title') }}
</h1>
<div class="flex">
<VaSwitch v-model="theme.fullName" label="Show full names" />
</div>
<VaForm ref="formRef">
<VaAccordion>
<VaCollapse>
<template #header-content
>Amenity
<VaChip
v-if="mapStore.filterForm.amenitySelect.length > 0"
class="ml-auto"
>{{ mapStore.filterForm.amenitySelect.length }}</VaChip
></template
>
<template #content>
<VaButton
icon="delete"
class="mb-3"
color="danger"
:disabled="mapStore.filterForm.amenitySelect.length <= 0"
@click="() => mapStore.filterForm.amenitySelect.splice(0)"
>Reset</VaButton
>
<VaVirtualScroller
v-slot="{ item }"
:items="amenities"
wrapper-size="30rem"
>
<VaCheckbox
preset="solid"
class="my-3 ml-1"
v-model="mapStore.filterForm.amenitySelect"
:array-value="item.value"
:label="
!theme.fullName
? item.value
: i18n.locale.value in item.lang
? item.lang[i18n.locale.value as 'en']
: item.lang.en
? item.lang.en
: item.value
"
/>
</VaVirtualScroller>
</template>
</VaCollapse>
<VaCollapse>
<template #header-content
>Objects <VaChip class="ml-auto">1</VaChip></template
>
<template #content> </template>
</VaCollapse>
<VaCollapse>
<template #header-content
>Objects <VaChip class="ml-auto">1</VaChip></template
>
<template #content> </template>
</VaCollapse>
</VaAccordion>
</VaForm>
</main>
</template>
<style scoped></style>
+1 -1
View File
@@ -3,7 +3,7 @@ import TheWelcome from '../components/TheWelcome.vue';
</script>
<template>
<main>
<main class="text-content">
<TheWelcome />
</main>
</template>
+72
View File
@@ -0,0 +1,72 @@
<script setup lang="ts">
import 'leaflet/dist/leaflet.css';
import {
LMap,
LMarker,
LPolygon,
LPolyline,
LPopup,
LTileLayer
} from '@vue-leaflet/vue-leaflet';
import L, { LatLng } from 'leaflet';
import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png';
import markerIcon from 'leaflet/dist/images/marker-icon.png';
import markerShadow from 'leaflet/dist/images/marker-shadow.png';
import { useMapStore } from '@/stores/map';
import { tagsToHTML } from '@/osm';
// @ts-ignore
delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: markerIcon2x,
iconUrl: markerIcon,
shadowUrl: markerShadow
});
const a =
'<a href="https://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a>';
const mapStore = useMapStore();
</script>
<template>
<main class="w-full" style="height: calc(100dvh - (48px + 75px))">
<l-map
ref="map"
v-model:zoom="mapStore.zoom"
:center="mapStore.center"
@update:center="(nc: LatLng) => (mapStore.center = [nc.lat, nc.lng])"
@update:bounds="(nb) => (mapStore.bounds = nb)"
@ready="(lmap) => (mapStore.bounds = lmap.getBounds())"
>
<l-tile-layer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
layer-type="base"
name="OpenStreetMap"
:attribution="$t('map.attribution', { a })"
/>
<l-marker
v-for="node in mapStore.nodes"
:key="node.id"
:lat-lng="[node.lat, node.lon]"
><l-popup
><div class="flex flex-col">
<div v-html="tagsToHTML(node.tags)" /></div></l-popup
></l-marker>
<l-polygon
v-for="way in mapStore.ways"
:key="`pg-${way.id}`"
:lat-lngs="way.geometry.map((g) => [g.lat, g.lon])"
><l-popup><div v-html="tagsToHTML(way.tags)" /></l-popup
></l-polygon>
<l-polyline
v-for="way in mapStore.ways"
:key="`pl-${way.id}`"
:lat-lngs="way.geometry.map((g) => [g.lat, g.lon])"
><l-popup><div v-html="tagsToHTML(way.tags)" /></l-popup
></l-polyline>
</l-map>
</main>
</template>
<style scoped></style>
+2 -1
View File
@@ -5,7 +5,8 @@
"vitest.config.*",
"cypress.config.*",
"nightwatch.conf.*",
"playwright.config.*"
"playwright.config.*",
"src/download/**/*"
],
"compilerOptions": {
"composite": true,