feat(map): update map features with bearing mode, improved export options, and new vector exchange panel

This commit is contained in:
Ivan
2026-04-23 02:26:29 -05:00
parent b187b6b00b
commit 2d175fb63e
6 changed files with 1823 additions and 176 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,46 @@
<!-- SPDX-License-Identifier: 0BSD -->
<template>
<div
class="absolute top-[calc(0.5rem+2.75rem+0.5rem+2.75rem)] left-1/2 -translate-x-1/2 z-[19] w-[min(100vw-2rem,24rem)] pointer-events-auto"
>
<div
class="bg-white/95 dark:bg-zinc-900/95 backdrop-blur border border-gray-200 dark:border-zinc-700 rounded-xl shadow-lg px-3 py-2 text-xs text-gray-800 dark:text-zinc-200"
>
<p class="font-medium text-center" :class="showFromHere ? 'mb-2' : ''">{{ instructionText }}</p>
<button
v-if="showFromHere"
type="button"
class="w-full py-1.5 rounded-lg bg-emerald-600 hover:bg-emerald-500 text-white text-[11px] font-semibold transition-colors"
@click="$emit('use-my-location')"
>
{{ $t("map.bearing_from_here") }}
</button>
</div>
</div>
</template>
<script>
export default {
name: "MapBearingInstructions",
props: {
fromGpsActive: { type: Boolean, default: false },
awaitingSecondTap: { type: Boolean, default: false },
},
emits: ["use-my-location"],
computed: {
instructionText() {
if (this.fromGpsActive) {
return this.$t("map.bearing_hint_destination");
}
if (this.awaitingSecondTap) {
return this.$t("map.bearing_hint_second");
}
return this.$t("map.bearing_hint_first");
},
showFromHere() {
return !this.fromGpsActive;
},
},
};
</script>
@@ -13,7 +13,7 @@
:ref="tool.type === 'Export' ? 'exportToolButton' : null"
class="p-1.5 sm:p-2 rounded-xl transition-all hover:scale-110 active:scale-90"
:class="[
(drawType === tool.type && !measuring) || (tool.type === 'Export' && exportMode)
(drawType === tool.type && !measuring && !bearingMode) || (tool.type === 'Export' && exportMode)
? 'bg-blue-500 text-white shadow-lg shadow-blue-500/30'
: 'hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-600 dark:text-gray-300',
]"
@@ -26,7 +26,7 @@
<button
class="p-1.5 sm:p-2 rounded-xl transition-all hover:scale-110 active:scale-90"
:class="[
measuring
measuring && !bearingMode
? 'bg-indigo-500 text-white shadow-lg shadow-indigo-500/30'
: 'hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-600 dark:text-gray-300',
]"
@@ -35,6 +35,30 @@
>
<v-icon icon="mdi-ruler" size="18" class="sm:!size-5"></v-icon>
</button>
<button
class="p-1.5 sm:p-2 rounded-xl transition-all hover:scale-110 active:scale-90"
:class="[
bearingMode
? 'bg-teal-600 text-white shadow-lg shadow-teal-600/30'
: 'hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-600 dark:text-gray-300',
]"
:title="$t('map.tool_bearing')"
@click="$emit('toggle-bearing')"
>
<v-icon icon="mdi-compass-outline" size="18" class="sm:!size-5"></v-icon>
</button>
<button
class="p-1.5 sm:p-2 rounded-xl transition-all hover:scale-110 active:scale-90"
:class="[
bearingMode && bearingFromGps
? 'bg-teal-600 text-white shadow-lg shadow-teal-600/30'
: 'hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-600 dark:text-gray-300',
]"
:title="$t('map.tool_bearing_from_here')"
@click="$emit('bearing-from-here')"
>
<v-icon icon="mdi-navigation-variant" size="18" class="sm:!size-5"></v-icon>
</button>
<button
class="p-1.5 sm:p-2 rounded-xl hover:bg-red-50 dark:hover:bg-red-900/20 text-red-500 transition-all hover:scale-110 active:scale-90"
:title="$t('map.tool_clear')"
@@ -81,6 +105,20 @@
>
<v-icon icon="mdi-crosshairs-gps" size="18" class="sm:!size-5"></v-icon>
</button>
<button
class="p-1.5 sm:p-2 rounded-xl hover:bg-emerald-50 dark:hover:bg-emerald-900/20 text-emerald-600 dark:text-emerald-400 transition-all hover:scale-110 active:scale-90"
:title="$t('map.share_view')"
@click="$emit('share-view')"
>
<v-icon icon="mdi-share-variant" size="18" class="sm:!size-5"></v-icon>
</button>
<button
class="p-1.5 sm:p-2 rounded-xl hover:bg-amber-50 dark:hover:bg-amber-900/20 text-amber-600 dark:text-amber-400 transition-all hover:scale-110 active:scale-90"
:title="$t('map.ping_here_toolbar')"
@click="$emit('ping-here')"
>
<v-icon icon="mdi-send" size="18" class="sm:!size-5"></v-icon>
</button>
</div>
</div>
</template>
@@ -92,6 +130,8 @@ export default {
tools: { type: Array, required: true },
drawType: { type: String, default: null },
measuring: { type: Boolean, default: false },
bearingMode: { type: Boolean, default: false },
bearingFromGps: { type: Boolean, default: false },
exportMode: { type: Boolean, default: false },
selectedFeature: { type: Object, default: null },
},
@@ -99,12 +139,16 @@ export default {
"toggle-draw",
"toggle-export",
"toggle-measure",
"toggle-bearing",
"bearing-from-here",
"clear",
"edit-note",
"delete-feature",
"save",
"load",
"locate",
"share-view",
"ping-here",
],
methods: {
onToolClick(tool) {
@@ -39,6 +39,9 @@
<span class="text-gray-600 dark:text-zinc-400">{{ $t("map.tile_count") }}:</span>
<span class="font-bold text-blue-600">{{ estimatedTiles }}</span>
</div>
<p v-if="tileLimitExceeded" class="text-xs text-red-600 dark:text-red-400 font-semibold">
{{ $t("map.export_tile_limit_exceeded") }}
</p>
<div class="flex gap-2">
<button
:disabled="exporting"
@@ -48,7 +51,7 @@
{{ $t("common.cancel") }}
</button>
<button
:disabled="exporting"
:disabled="exporting || tileLimitExceeded"
class="flex-1 py-2 bg-blue-500 hover:bg-blue-600 disabled:bg-blue-300 text-white rounded-lg font-bold transition-colors shadow-md"
@click="$emit('start')"
>
@@ -70,6 +73,7 @@ export default {
maxZoom: { type: Number, required: true },
estimatedTiles: { type: [Number, String], default: 0 },
exporting: { type: Boolean, default: false },
tileLimitExceeded: { type: Boolean, default: false },
},
emits: ["cancel", "start", "update:minZoom", "update:maxZoom"],
};
@@ -2,14 +2,33 @@
<template>
<div
class="absolute top-4 left-1/2 -translate-x-1/2 z-20 px-4 py-2 bg-blue-600 text-white rounded-full shadow-lg font-medium text-sm animate-bounce"
class="absolute top-4 left-1/2 -translate-x-1/2 z-20 flex flex-col items-center gap-2 max-w-[min(100vw-2rem,36rem)] px-2"
>
{{ $t("map.export_instructions") }}
<div
class="px-4 py-2 bg-blue-600 text-white rounded-full shadow-lg font-medium text-sm animate-bounce text-center"
>
{{ $t("map.export_instructions") }}
</div>
<div v-if="presets && presets.length" class="flex flex-wrap justify-center gap-2 pointer-events-auto">
<button
v-for="p in presets"
:key="p.id"
type="button"
class="px-2 py-1 text-[10px] font-bold uppercase tracking-tight rounded-lg bg-white/95 dark:bg-zinc-900/95 border border-gray-200 dark:border-zinc-700 text-gray-800 dark:text-zinc-100 shadow-sm hover:bg-gray-50 dark:hover:bg-zinc-800"
@click="$emit('select-preset', p)"
>
{{ $t(`map.export_region_${p.id}`) }}
</button>
</div>
</div>
</template>
<script>
export default {
name: "MapExportInstructions",
props: {
presets: { type: Array, default: () => [] },
},
emits: ["select-preset"],
};
</script>
@@ -0,0 +1,184 @@
<!-- SPDX-License-Identifier: 0BSD -->
<template>
<div class="space-y-3 rounded-xl border border-gray-200 dark:border-zinc-800 bg-gray-50/50 dark:bg-zinc-900/40 p-3">
<div class="flex items-center justify-between gap-2">
<span class="text-[10px] font-bold text-gray-500 dark:text-zinc-500 uppercase tracking-widest">{{
$t("map.vector_exchange_title")
}}</span>
</div>
<label class="flex items-center gap-2 text-[10px] text-gray-600 dark:text-zinc-400 cursor-pointer select-none">
<input v-model="mergeImport" type="checkbox" class="rounded border-gray-300 dark:border-zinc-600" />
{{ $t("map.vector_exchange_merge") }}
</label>
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
<button
type="button"
class="py-2 px-2 text-[10px] font-bold uppercase rounded-lg bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-700 text-gray-800 dark:text-zinc-100 hover:bg-gray-50 dark:hover:bg-zinc-800 disabled:opacity-40"
:disabled="disabled"
@click="triggerGeoJsonPick"
>
{{ $t("map.vector_import_geojson") }}
</button>
<button
type="button"
class="py-2 px-2 text-[10px] font-bold uppercase rounded-lg bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-700 text-gray-800 dark:text-zinc-100 hover:bg-gray-50 dark:hover:bg-zinc-800 disabled:opacity-40"
:disabled="disabled"
@click="triggerKmlPick"
>
{{ $t("map.vector_import_kml") }}
</button>
<button
type="button"
class="py-2 px-2 text-[10px] font-bold uppercase rounded-lg bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-700 text-gray-800 dark:text-zinc-100 hover:bg-gray-50 dark:hover:bg-zinc-800 disabled:opacity-40"
:disabled="disabled"
@click="triggerKmzPick"
>
{{ $t("map.vector_import_kmz") }}
</button>
<button
type="button"
class="flex items-center justify-center px-2 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg transition-all text-[10px] font-bold uppercase tracking-tight shadow-sm active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="disabled || !hasFeatures"
@click="$emit('export-geojson')"
>
{{ $t("map.vector_export_geojson") }}
</button>
<button
type="button"
class="flex items-center justify-center px-2 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg transition-all text-[10px] font-bold uppercase tracking-tight shadow-sm active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="disabled || !hasFeatures"
@click="$emit('export-kml')"
>
{{ $t("map.vector_export_kml") }}
</button>
<button
type="button"
class="flex items-center justify-center px-2 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg transition-all text-[10px] font-bold uppercase tracking-tight shadow-sm active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="disabled || !hasFeatures"
@click="$emit('export-kmz')"
>
{{ $t("map.vector_export_kmz") }}
</button>
</div>
<p class="text-[9px] text-gray-500 dark:text-zinc-500 leading-snug">
{{ $t("map.vector_exchange_hint") }}
</p>
<input
ref="geojsonInput"
type="file"
accept=".geojson,.json,application/geo+json,application/json"
class="hidden"
@change="onGeojsonFile"
/>
<input
ref="kmlInput"
type="file"
accept=".kml,.xml,text/xml,application/vnd.google-earth.kml+xml"
class="hidden"
@change="onKmlFile"
/>
<input
ref="kmzInput"
type="file"
accept=".kmz,application/vnd.google-earth.kmz,application/zip"
class="hidden"
@change="onKmzFile"
/>
</div>
</template>
<script>
import { readGeoJsonToFeatures } from "../../../js/mapExchange/geoJsonCodec.js";
import { readKmlToFeatures } from "../../../js/mapExchange/kmlCodec.js";
import { readKmzToFeatures } from "../../../js/mapExchange/kmzCodec.js";
export default {
name: "MapVectorExchangePanel",
props: {
disabled: { type: Boolean, default: false },
hasFeatures: { type: Boolean, default: false },
},
emits: ["import-features", "export-geojson", "export-kml", "export-kmz", "import-error"],
data() {
return {
mergeImport: true,
};
},
methods: {
triggerGeoJsonPick() {
this.$refs.geojsonInput?.click();
},
triggerKmlPick() {
this.$refs.kmlInput?.click();
},
triggerKmzPick() {
this.$refs.kmzInput?.click();
},
async readFileText(file) {
return new Promise((resolve, reject) => {
const r = new FileReader();
r.onload = () => resolve(String(r.result || ""));
r.onerror = () => reject(new Error("read failed"));
r.readAsText(file);
});
},
async onGeojsonFile(ev) {
const input = ev.target;
const file = input.files && input.files[0];
input.value = "";
if (!file) {
return;
}
try {
const text = await this.readFileText(file);
const features = readGeoJsonToFeatures(text, "EPSG:3857");
this.$emit("import-features", { features, merge: this.mergeImport });
} catch (e) {
console.error(e);
this.$emit("import-error", e);
}
},
async onKmlFile(ev) {
const input = ev.target;
const file = input.files && input.files[0];
input.value = "";
if (!file) {
return;
}
try {
const text = await this.readFileText(file);
const features = readKmlToFeatures(text, "EPSG:3857");
this.$emit("import-features", { features, merge: this.mergeImport });
} catch (e) {
console.error(e);
this.$emit("import-error", e);
}
},
async readFileArrayBuffer(file) {
return new Promise((resolve, reject) => {
const r = new FileReader();
r.onload = () => resolve(/** @type {ArrayBuffer} */ (r.result));
r.onerror = () => reject(new Error("read failed"));
r.readAsArrayBuffer(file);
});
},
async onKmzFile(ev) {
const input = ev.target;
const file = input.files && input.files[0];
input.value = "";
if (!file) {
return;
}
try {
const buf = await this.readFileArrayBuffer(file);
const features = await readKmzToFeatures(buf, "EPSG:3857");
this.$emit("import-features", { features, merge: this.mergeImport });
} catch (e) {
console.error(e);
this.$emit("import-error", e);
}
},
},
};
</script>