mirror of
https://github.com/directus/directus.git
synced 2026-04-25 03:00:53 -04:00
Add support for Geometry type, add Map Layout & Interface (#5684)
* Added map layout * Cleanup and bug fixes * Removed package-lock * Cleanup and fixes * Small fix * Added back package-lock * Saved camera, autofitting option, bug fixes * Refactor and ui improvements * Improvements * Added seled mode * Removed unused dependency * Changed selection behaviour, cleanup. * update import and dependencies * make custom style into drawer * remove unused imports * use lodash functions * add popups * allow header to become small * reorganize settings * add styling to popup * change default template * add projection option * add basic map interface * finish simple map * add mapbox style * support more mapbox layouts * add api key option * add mapbox backgrounds to layout * warn when no api key is set * fix for latest version * Improved map layout and interface, bug fixes, refactoring. . . * Added postgis geometry format, added marker icon shadow * Made map buttons bigger and their icons thinner. Added transition to header bar. * Bug fixes and error handling in map interface. * Moved box-select control out of the map component. Removed material icons sprite and use addImage for marker support. * Handle MultiGeometry -> Geometry interface error. * Removed hardcoded styles. Added migrations for basemap column. Lots of refactoring. Removed hardcoded styles. Added migrations for basemap column. Lots of refactoring. * Fixed style reloading error. Added translations. * Moved worker code to lib. * Removed worker code. Prevent Mapbox from removing access_token from the URL. * Refactoring. * Change basemap selection to in-map dropdown for layout and interface. * Touchscreen selection support and small fixes. * Small change. * Fixed unused imports. * Added support for PostgreSQL identity column * Renamed migration. Added crs translation. * Only show fields using the map interface in the map layout. * Removed logging. * Reverted Dockerfile change. * Improved crs support. * Fixed translations. * Check for schema identity before updating it. * Fixed popup not updating on feature hover. * Added feature hover styling. Fixed layer customization input. Added out of bounds error handling. * Added geometry type and support for database native geometries. * Fixed linting. * Fixed layout. * Fixed layout. * Actually fixed linting * Full support for native geometries Fixed basemap input Improved feature popup on hover Locked interfaced support * Fixed geometryType option not updating * Bug fixes in interface * Fixed crash when empty basemap settings. Fixed fitBounds option not updating. * Added back storage type option. Improved interface behaviour. * Dropped wkb because of vendor inconsistency with binary data * Updated layout to match new geometry type. Fixed geojson payload transform. * Added missing geometry_format attributes to local types. * Fixed typos & refactoring * Removed dependency on proj4 * Fix error when empty map interface options * Set geometry SRID to 4326 when inserting into the database * Add support for selectMode * Fix error on initial source load * Added geocoder, use GeoJSON for api i/o, removed geometry_format option, refactoring * Added geometry intersects filter. Created geometry helper class. * Fix error when null geometryOptions, added mapbox_key setting. * Moved all geometry parsing/serializing into processGeometries in `payload.ts`. Fixed type errors. * Migrate to Vue 3 * Use wellknown instead of wkx * Fixed basemap selection. * Added available operator for geometry type * Added nintersects filter, fixed map interface for filter input * Added intersects_bbox filter & bug fixes. * Fixed icons rendering * Fixed cursor icon in select mode * Added geometry aggregate function * Fixed geometry processing bug when imported from relational field. * Fixed error with geocoder instanciation * Removed @types/maplibre-gl dependency * Removed fitViewToData options * Merge remote-tracking branch 'upstream/main' into map-layout * Fixed style and geometryType in map interface options * Fixed style change on map interface. * Improved fitViewToData behaviour * Fixed type imports and previous merge conflict * Fixed linting * Added available operators * Fix and merge migrations * Remove outdated p-queue dep * Fix get-schema column extract * Replace pg with postgis for local debugging * Re-add missing import * Add mapbox as a basemap when key exists * Remove unused tz flag * Process delta in payloadservice * Set default map, add limit number styling * Default display template to just PK * Tweak styling of error dialog * Fix method usage in helpers * Move sdo_geo to oracle section * Remove extensions from ts config exclude * Move geo types to shared, remove _Geometry * Remove unused type * Tiny Tweaks * Remove fit to bounds option in favor of on * Validate incoming intersects query * Deepmap filter values * Add GraphQL support * No defaultValue for geometryType * Resolve c * Fix translations Co-authored-by: Nitwel <nitwel@arcor.de> Co-authored-by: Rijk van Zanten <rijkvanzanten@me.com>
This commit is contained in:
@@ -7,7 +7,7 @@
|
||||
</div>
|
||||
|
||||
<transition-expand>
|
||||
<div v-show="active" class="fields">
|
||||
<div v-if="active" class="fields">
|
||||
<v-form
|
||||
:initial-values="initialValues"
|
||||
:fields="fieldsInSection"
|
||||
|
||||
@@ -33,7 +33,7 @@ export default defineInterface({
|
||||
description: '$t:interfaces.input-code.description',
|
||||
icon: 'code',
|
||||
component: InterfaceCode,
|
||||
types: ['string', 'json', 'text'],
|
||||
types: ['string', 'json', 'text', 'geometry'],
|
||||
options: [
|
||||
{
|
||||
field: 'language',
|
||||
|
||||
@@ -8,6 +8,6 @@ export default defineInterface({
|
||||
description: '$t:interfaces.input.description',
|
||||
icon: 'text_fields',
|
||||
component: InterfaceInput,
|
||||
types: ['string', 'uuid', 'bigInteger', 'integer', 'float', 'decimal'],
|
||||
types: ['string', 'uuid', 'bigInteger', 'integer', 'float', 'decimal', 'geometry'],
|
||||
options: Options,
|
||||
});
|
||||
|
||||
14
app/src/interfaces/map/index.ts
Normal file
14
app/src/interfaces/map/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineInterface } from '@directus/shared/utils';
|
||||
import InterfaceMap from './map.vue';
|
||||
import Options from './options.vue';
|
||||
|
||||
export default defineInterface({
|
||||
id: 'map',
|
||||
name: '$t:interfaces.map.map',
|
||||
description: '$t:interfaces.map.description',
|
||||
icon: 'map',
|
||||
component: InterfaceMap,
|
||||
types: ['geometry', 'json', 'string', 'text', 'binary', 'csv'],
|
||||
options: Options,
|
||||
recommendedDisplays: [],
|
||||
});
|
||||
449
app/src/interfaces/map/map.vue
Normal file
449
app/src/interfaces/map/map.vue
Normal file
@@ -0,0 +1,449 @@
|
||||
<template>
|
||||
<div class="interface-map">
|
||||
<div
|
||||
ref="container"
|
||||
class="map"
|
||||
:class="{ loading: mapLoading, error: geometryParsingError || geometryOptionsError }"
|
||||
/>
|
||||
<div class="mapboxgl-ctrl-group mapboxgl-ctrl mapboxgl-ctrl-dropdown basemap-select">
|
||||
<v-icon name="map" />
|
||||
<v-select v-model="basemap" inline :items="basemaps.map((s) => ({ text: s.name, value: s.name }))" />
|
||||
</div>
|
||||
<transition name="fade">
|
||||
<v-info
|
||||
v-if="geometryOptionsError"
|
||||
icon="error"
|
||||
center
|
||||
type="danger"
|
||||
:title="t('interfaces.map.invalid_options')"
|
||||
>
|
||||
<v-notice type="danger" :icon="false">
|
||||
{{ geometryOptionsError }}
|
||||
</v-notice>
|
||||
</v-info>
|
||||
<v-info
|
||||
v-else-if="geometryParsingError"
|
||||
icon="error"
|
||||
center
|
||||
type="warning"
|
||||
:title="t('layouts.map.invalid_geometry')"
|
||||
>
|
||||
<v-notice type="warning" :icon="false">
|
||||
{{ geometryParsingError }}
|
||||
</v-notice>
|
||||
<template #append>
|
||||
<v-card-actions>
|
||||
<v-button small class="soft-reset" secondary @click="resetValue(false)">{{ t('continue') }}</v-button>
|
||||
<v-button small class="hard-reset" @click="resetValue(true)">{{ t('reset') }}</v-button>
|
||||
</v-card-actions>
|
||||
</template>
|
||||
</v-info>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import '@mapbox/mapbox-gl-draw/dist/mapbox-gl-draw.css';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import { defineComponent, onMounted, onUnmounted, PropType, ref, watch, toRefs, computed } from 'vue';
|
||||
import {
|
||||
LngLatBoundsLike,
|
||||
AnimationOptions,
|
||||
CameraOptions,
|
||||
Map,
|
||||
NavigationControl,
|
||||
GeolocateControl,
|
||||
IControl,
|
||||
} from 'maplibre-gl';
|
||||
import MapboxDraw from '@mapbox/mapbox-gl-draw';
|
||||
// @ts-ignore
|
||||
import StaticMode from '@mapbox/mapbox-gl-draw-static-mode';
|
||||
import MapboxGeocoder from '@mapbox/mapbox-gl-geocoder';
|
||||
import '@mapbox/mapbox-gl-geocoder/dist/mapbox-gl-geocoder.css';
|
||||
import { ButtonControl } from '@/utils/geometry/controls';
|
||||
import { Geometry } from 'geojson';
|
||||
import { flatten, getBBox, getParser, getSerializer, getGeometryFormatForType } from '@/utils/geometry';
|
||||
import { GeoJSONParser, GeoJSONSerializer, SimpleGeometry, MultiGeometry } from '@directus/shared/types';
|
||||
import getSetting from '@/utils/get-setting';
|
||||
import { snakeCase, isEqual } from 'lodash';
|
||||
import styles from './style';
|
||||
import { Field, GeometryType, GeometryFormat } from '@directus/shared/types';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { TranslateResult } from 'vue-i18n';
|
||||
import { useAppStore } from '@/stores';
|
||||
|
||||
import { getBasemapSources, getStyleFromBasemapSource } from '@/utils/geometry/basemap';
|
||||
|
||||
const MARKER_ICON_URL =
|
||||
'https://cdn.jsdelivr.net/gh/google/material-design-icons/png/maps/place/materialicons/24dp/1x/baseline_place_black_24dp.png';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
type: {
|
||||
type: String as PropType<'geometry' | 'json' | 'csv' | 'string' | 'text'>,
|
||||
default: null,
|
||||
},
|
||||
fieldData: {
|
||||
type: Object as PropType<Field | undefined>,
|
||||
default: undefined,
|
||||
},
|
||||
value: {
|
||||
type: [Object, Array, String] as PropType<any>,
|
||||
default: null,
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
geometryFormat: {
|
||||
type: String as PropType<GeometryFormat>,
|
||||
default: undefined,
|
||||
},
|
||||
geometryType: {
|
||||
type: String as PropType<GeometryType>,
|
||||
default: undefined,
|
||||
},
|
||||
defaultView: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
emits: ['input'],
|
||||
setup(props, { emit }) {
|
||||
const { t } = useI18n();
|
||||
const container = ref<HTMLElement | null>(null);
|
||||
let map: Map;
|
||||
let mapLoading = ref(true);
|
||||
let currentGeometry: Geometry | null | undefined;
|
||||
|
||||
const geometryOptionsError = ref<string | null>();
|
||||
const geometryParsingError = ref<string | TranslateResult>();
|
||||
|
||||
const geometryType = (props.fieldData?.schema?.geometry_type ?? props.geometryType) as GeometryType;
|
||||
const geometryFormat = props.geometryFormat || getGeometryFormatForType(props.type)!;
|
||||
|
||||
const basemaps = getBasemapSources();
|
||||
const appStore = useAppStore();
|
||||
const { basemap } = toRefs(appStore);
|
||||
const style = computed(() => {
|
||||
const source = basemaps.find((source) => source.name == basemap.value) ?? basemaps[0];
|
||||
return basemap.value, getStyleFromBasemapSource(source);
|
||||
});
|
||||
|
||||
let parse: GeoJSONParser;
|
||||
let serialize: GeoJSONSerializer;
|
||||
try {
|
||||
parse = getParser({ geometryFormat, geometryField: 'value' });
|
||||
serialize = getSerializer({ geometryFormat, geometryField: 'value' });
|
||||
} catch (error) {
|
||||
geometryOptionsError.value = error;
|
||||
}
|
||||
|
||||
const mapboxKey = getSetting('mapbox_key');
|
||||
|
||||
const controls = {
|
||||
draw: new MapboxDraw(getDrawOptions(geometryType)),
|
||||
fitData: new ButtonControl('mapboxgl-ctrl-fitdata', fitDataBounds),
|
||||
navigation: new NavigationControl(),
|
||||
geolocate: new GeolocateControl(),
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
setupMap();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
map.remove();
|
||||
});
|
||||
|
||||
return {
|
||||
t,
|
||||
container,
|
||||
mapLoading,
|
||||
resetValue,
|
||||
geometryParsingError,
|
||||
geometryOptionsError,
|
||||
basemaps,
|
||||
basemap,
|
||||
};
|
||||
|
||||
function setupMap() {
|
||||
map = new Map({
|
||||
container: container.value!,
|
||||
style: style.value,
|
||||
attributionControl: false,
|
||||
...props.defaultView,
|
||||
...(mapboxKey ? { accessToken: mapboxKey } : {}),
|
||||
});
|
||||
|
||||
map.addControl(controls.navigation, 'top-left');
|
||||
map.addControl(controls.geolocate, 'top-left');
|
||||
map.addControl(controls.fitData, 'top-left');
|
||||
map.addControl(controls.draw as IControl, 'top-left');
|
||||
|
||||
if (mapboxKey) {
|
||||
map.addControl(new MapboxGeocoder({ accessToken: mapboxKey, marker: false }), 'top-right');
|
||||
}
|
||||
|
||||
map.on('load', async () => {
|
||||
map.resize();
|
||||
mapLoading.value = false;
|
||||
await addMarkerImage();
|
||||
map.on('basemapselect', () => {
|
||||
map.once('styledata', async () => {
|
||||
await addMarkerImage();
|
||||
});
|
||||
});
|
||||
map.on('draw.create', handleDrawUpdate);
|
||||
map.on('draw.delete', handleDrawUpdate);
|
||||
map.on('draw.update', handleDrawUpdate);
|
||||
map.on('draw.modechange', handleDrawModeChange);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.value,
|
||||
(value) => {
|
||||
if (!value) {
|
||||
controls.draw.deleteAll();
|
||||
currentGeometry = null;
|
||||
if (geometryType) {
|
||||
const snaked = snakeCase(geometryType.replace('Multi', ''));
|
||||
const mode = `draw_${snaked}` as any;
|
||||
controls.draw.changeMode(mode);
|
||||
}
|
||||
} else {
|
||||
if (!isEqual(value, currentGeometry && serialize(currentGeometry))) {
|
||||
loadValueFromProps();
|
||||
}
|
||||
}
|
||||
if (props.disabled) {
|
||||
controls.draw.changeMode('static');
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => style.value,
|
||||
async () => {
|
||||
map.removeControl(controls.draw);
|
||||
map.setStyle(style.value, { diff: false });
|
||||
controls.draw = new MapboxDraw(getDrawOptions(geometryType));
|
||||
await addMarkerImage();
|
||||
map.addControl(controls.draw as IControl, 'top-left');
|
||||
loadValueFromProps();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function resetValue(hard: boolean) {
|
||||
geometryParsingError.value = undefined;
|
||||
if (hard) emit('input', null);
|
||||
}
|
||||
|
||||
function addMarkerImage() {
|
||||
return new Promise((resolve, reject) => {
|
||||
map.loadImage(MARKER_ICON_URL, (error: any, image: any) => {
|
||||
if (error) reject(error);
|
||||
map.addImage('place', image, { sdf: true });
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function fitDataBounds(options: CameraOptions & AnimationOptions) {
|
||||
if (map && currentGeometry) {
|
||||
map.fitBounds(currentGeometry.bbox! as LngLatBoundsLike, {
|
||||
padding: 80,
|
||||
maxZoom: 8,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function getDrawOptions(type: GeometryType): any {
|
||||
const options = {
|
||||
styles,
|
||||
controls: {},
|
||||
userProperties: true,
|
||||
displayControlsDefault: false,
|
||||
modes: Object.assign(MapboxDraw.modes, {
|
||||
static: StaticMode,
|
||||
}),
|
||||
} as any;
|
||||
if (props.disabled) {
|
||||
return options;
|
||||
}
|
||||
if (!type) {
|
||||
options.controls.line_string = true;
|
||||
options.controls.polygon = true;
|
||||
options.controls.point = true;
|
||||
options.controls.trash = true;
|
||||
return options;
|
||||
} else {
|
||||
const base = snakeCase(type!.replace('Multi', ''));
|
||||
options.controls[base] = true;
|
||||
options.controls.trash = true;
|
||||
return options;
|
||||
}
|
||||
}
|
||||
|
||||
function isTypeCompatible(a?: GeometryType, b?: GeometryType): boolean {
|
||||
if (!a || !b) {
|
||||
return true;
|
||||
}
|
||||
if (a.startsWith('Multi')) {
|
||||
return a.replace('Multi', '') == b.replace('Multi', '');
|
||||
}
|
||||
return a == b;
|
||||
}
|
||||
|
||||
function loadValueFromProps() {
|
||||
try {
|
||||
controls.draw.deleteAll();
|
||||
const initialValue = parse(props);
|
||||
if (!props.disabled && !isTypeCompatible(geometryType, initialValue!.type)) {
|
||||
geometryParsingError.value = t('interfaces.map.unexpected_geometry', {
|
||||
expected: geometryType,
|
||||
got: initialValue!.type,
|
||||
});
|
||||
}
|
||||
const flattened = flatten(initialValue);
|
||||
for (const geometry of flattened) {
|
||||
controls.draw.add(geometry);
|
||||
}
|
||||
currentGeometry = getCurrentGeometry();
|
||||
currentGeometry!.bbox = getBBox(currentGeometry!);
|
||||
|
||||
if (geometryParsingError.value) {
|
||||
const bbox = getBBox(initialValue!) as LngLatBoundsLike;
|
||||
map.fitBounds(bbox, { padding: 0, maxZoom: 8, duration: 0 });
|
||||
} else {
|
||||
fitDataBounds({ duration: 0 });
|
||||
}
|
||||
} catch (error) {
|
||||
geometryParsingError.value = error;
|
||||
}
|
||||
}
|
||||
|
||||
function getCurrentGeometry(): Geometry | null {
|
||||
const features = controls.draw.getAll().features;
|
||||
const geometries = features.map((f) => f.geometry) as (SimpleGeometry | MultiGeometry)[];
|
||||
let result: Geometry;
|
||||
if (geometries.length == 0) {
|
||||
return null;
|
||||
} else if (!geometryType) {
|
||||
if (geometries.length > 1) {
|
||||
result = { type: 'GeometryCollection', geometries };
|
||||
} else {
|
||||
result = geometries[0];
|
||||
}
|
||||
} else if (geometryType.startsWith('Multi')) {
|
||||
const coordinates = geometries
|
||||
.filter(({ type }) => `Multi${type}` == geometryType)
|
||||
.map(({ coordinates }) => coordinates);
|
||||
result = { type: geometryType, coordinates } as Geometry;
|
||||
} else {
|
||||
result = geometries[geometries.length - 1];
|
||||
}
|
||||
result!.bbox = getBBox(result!);
|
||||
return result;
|
||||
}
|
||||
|
||||
function handleDrawModeChange(event: any) {
|
||||
if (!props.disabled && event.mode.startsWith('draw') && geometryType && !geometryType.startsWith('Multi')) {
|
||||
for (const feature of controls.draw.getAll().features.slice(0, -1)) {
|
||||
controls.draw.delete(feature.id as string);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleDrawUpdate() {
|
||||
currentGeometry = getCurrentGeometry();
|
||||
if (!currentGeometry) {
|
||||
controls.draw.deleteAll();
|
||||
emit('input', null);
|
||||
} else {
|
||||
emit('input', serialize(currentGeometry));
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.mapbox-gl-draw_point::after {
|
||||
content: 'add_location';
|
||||
}
|
||||
|
||||
.mapbox-gl-draw_line::after {
|
||||
content: 'timeline';
|
||||
}
|
||||
|
||||
.mapbox-gl-draw_polygon::after {
|
||||
content: 'category';
|
||||
}
|
||||
|
||||
.mapbox-gl-draw_trash::after {
|
||||
content: 'delete';
|
||||
}
|
||||
|
||||
.mapbox-gl-draw_uncombine::after {
|
||||
content: 'call_split';
|
||||
}
|
||||
|
||||
.mapbox-gl-draw_combine::after {
|
||||
content: 'call_merge';
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.interface-map {
|
||||
overflow: hidden;
|
||||
border: var(--border-width) solid var(--border-normal);
|
||||
border-radius: var(--border-radius);
|
||||
|
||||
.map {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 500px;
|
||||
|
||||
&.error,
|
||||
&.loading {
|
||||
opacity: 0.25;
|
||||
}
|
||||
}
|
||||
|
||||
.v-info {
|
||||
padding: 20px;
|
||||
background-color: var(--background-subdued);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--card-shadow);
|
||||
}
|
||||
|
||||
.basemap-select {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.center {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
-webkit-transform: translate(-50%, -50%);
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.v-button.hard-reset {
|
||||
--v-button-background-color: var(--danger-10);
|
||||
--v-button-color: var(--danger);
|
||||
--v-button-background-color-hover: var(--danger-25);
|
||||
--v-button-color-hover: var(--danger);
|
||||
}
|
||||
</style>
|
||||
132
app/src/interfaces/map/options.vue
Normal file
132
app/src/interfaces/map/options.vue
Normal file
@@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<div class="form-grid">
|
||||
<div class="field half-left">
|
||||
<div class="type-label">{{ t('interfaces.map.geometry_format') }}</div>
|
||||
<v-input v-model="geometryFormat" :disabled="true" :value="t(`interfaces.map.${compatibleFormat}`)" />
|
||||
</div>
|
||||
<div class="field half-right">
|
||||
<div class="type-label">{{ t('interfaces.map.geometry_type') }}</div>
|
||||
<v-select
|
||||
v-model="geometryType"
|
||||
:placeholder="t('any')"
|
||||
:show-deselect="true"
|
||||
:disabled="!!nativeGeometryType || geometryFormat == 'lnglat'"
|
||||
:items="GEOMETRY_TYPES.map((value) => ({ value, text: value }))"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="type-label">{{ t('interfaces.map.default_view') }}</div>
|
||||
<div ref="mapContainer" class="map"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { ref, defineComponent, PropType, watch, onMounted, onUnmounted, computed, toRefs } from 'vue';
|
||||
import { GEOMETRY_TYPES } from '@directus/shared/constants';
|
||||
import { Field, GeometryType, GeometryFormat, GeometryOptions } from '@directus/shared/types';
|
||||
import { getGeometryFormatForType } from '@/utils/geometry';
|
||||
import { getBasemapSources, getStyleFromBasemapSource } from '@/utils/geometry/basemap';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import { Map, CameraOptions } from 'maplibre-gl';
|
||||
import { useAppStore } from '@/stores';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
collection: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
fieldData: {
|
||||
type: Object as PropType<Field>,
|
||||
default: null,
|
||||
},
|
||||
value: {
|
||||
type: Object as PropType<GeometryOptions & { defaultView?: CameraOptions }>,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
emits: ['input'],
|
||||
setup(props, { emit }) {
|
||||
const { t } = useI18n();
|
||||
|
||||
const isGeometry = props.fieldData.type == 'geometry';
|
||||
const nativeGeometryType = isGeometry ? (props.fieldData!.schema!.geometry_type as GeometryType) : undefined;
|
||||
const compatibleFormat = isGeometry ? ('native' as const) : getGeometryFormatForType(props.fieldData.type);
|
||||
|
||||
const geometryFormat = ref<GeometryFormat>(compatibleFormat!);
|
||||
const geometryType = ref<GeometryType>(
|
||||
geometryFormat.value == 'lnglat' ? 'Point' : nativeGeometryType ?? props.value?.geometryType
|
||||
);
|
||||
const defaultView = ref<CameraOptions | undefined>(props.value?.defaultView);
|
||||
|
||||
watch(
|
||||
[geometryFormat, geometryType, defaultView],
|
||||
() => {
|
||||
const type = geometryFormat.value == 'lnglat' ? 'Point' : geometryType;
|
||||
emit('input', { defaultView, geometryFormat, geometryType: type });
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const mapContainer = ref<HTMLElement | null>(null);
|
||||
let map: Map;
|
||||
|
||||
const basemaps = getBasemapSources();
|
||||
const appStore = useAppStore();
|
||||
const { basemap } = toRefs(appStore);
|
||||
const style = computed(() => {
|
||||
const source = basemaps.find((source) => source.name == basemap.value) ?? basemaps[0];
|
||||
return getStyleFromBasemapSource(source);
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
map = new Map({
|
||||
container: mapContainer.value!,
|
||||
style: style.value,
|
||||
attributionControl: false,
|
||||
...(defaultView.value || {}),
|
||||
});
|
||||
map.on('moveend', () => {
|
||||
defaultView.value = {
|
||||
center: map.getCenter(),
|
||||
zoom: map.getZoom(),
|
||||
bearing: map.getBearing(),
|
||||
pitch: map.getPitch(),
|
||||
};
|
||||
});
|
||||
});
|
||||
onUnmounted(() => {
|
||||
map.remove();
|
||||
});
|
||||
|
||||
return {
|
||||
t,
|
||||
isGeometry,
|
||||
nativeGeometryType,
|
||||
compatibleFormat,
|
||||
geometryFormat,
|
||||
GEOMETRY_TYPES,
|
||||
geometryType,
|
||||
mapContainer,
|
||||
fitBounds,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/styles/mixins/form-grid';
|
||||
|
||||
.form-grid {
|
||||
@include form-grid;
|
||||
}
|
||||
|
||||
.map {
|
||||
height: 400px;
|
||||
overflow: hidden;
|
||||
border: var(--border-width) solid var(--border-normal);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
</style>
|
||||
219
app/src/interfaces/map/style.ts
Normal file
219
app/src/interfaces/map/style.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
export default [
|
||||
{
|
||||
id: 'directus-polygon-fill-inactive',
|
||||
type: 'fill',
|
||||
filter: ['all', ['==', 'active', 'false'], ['==', '$type', 'Polygon'], ['!=', 'mode', 'static']],
|
||||
paint: {
|
||||
'fill-color': '#3bb2d0',
|
||||
'fill-outline-color': '#3bb2d0',
|
||||
'fill-opacity': 0.1,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'directus-polygon-fill-active',
|
||||
type: 'fill',
|
||||
filter: ['all', ['==', 'active', 'true'], ['==', '$type', 'Polygon']],
|
||||
paint: {
|
||||
'fill-color': '#fbb03b',
|
||||
'fill-outline-color': '#fbb03b',
|
||||
'fill-opacity': 0.1,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'directus-polygon-midpoint',
|
||||
type: 'circle',
|
||||
filter: ['all', ['==', '$type', 'Point'], ['==', 'meta', 'midpoint']],
|
||||
paint: {
|
||||
'circle-radius': 3,
|
||||
'circle-color': '#fbb03b',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'directus-polygon-stroke-inactive',
|
||||
type: 'line',
|
||||
filter: ['all', ['==', 'active', 'false'], ['==', '$type', 'Polygon'], ['!=', 'mode', 'static']],
|
||||
layout: {
|
||||
'line-cap': 'round',
|
||||
'line-join': 'round',
|
||||
},
|
||||
paint: {
|
||||
'line-color': '#3bb2d0',
|
||||
'line-width': 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'directus-polygon-stroke-active',
|
||||
type: 'line',
|
||||
filter: ['all', ['==', 'active', 'true'], ['==', '$type', 'Polygon']],
|
||||
layout: {
|
||||
'line-cap': 'round',
|
||||
'line-join': 'round',
|
||||
},
|
||||
paint: {
|
||||
'line-color': '#fbb03b',
|
||||
'line-dasharray': [0.2, 2],
|
||||
'line-width': 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'directus-line-inactive',
|
||||
type: 'line',
|
||||
filter: ['all', ['==', 'active', 'false'], ['==', '$type', 'LineString'], ['!=', 'mode', 'static']],
|
||||
layout: {
|
||||
'line-cap': 'round',
|
||||
'line-join': 'round',
|
||||
},
|
||||
paint: {
|
||||
'line-color': '#3bb2d0',
|
||||
'line-width': 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'directus-line-active',
|
||||
type: 'line',
|
||||
filter: ['all', ['==', '$type', 'LineString'], ['==', 'active', 'true']],
|
||||
layout: {
|
||||
'line-cap': 'round',
|
||||
'line-join': 'round',
|
||||
},
|
||||
paint: {
|
||||
'line-color': '#fbb03b',
|
||||
'line-dasharray': [0.2, 2],
|
||||
'line-width': 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'directus-polygon-and-line-vertex-stroke-inactive',
|
||||
type: 'circle',
|
||||
filter: ['all', ['==', 'meta', 'vertex'], ['==', '$type', 'Point'], ['!=', 'mode', 'static']],
|
||||
paint: {
|
||||
'circle-radius': 5,
|
||||
'circle-color': '#fff',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'directus-polygon-and-line-vertex-inactive',
|
||||
type: 'circle',
|
||||
filter: ['all', ['==', 'meta', 'vertex'], ['==', '$type', 'Point'], ['!=', 'mode', 'static']],
|
||||
paint: {
|
||||
'circle-radius': 3,
|
||||
'circle-color': '#fbb03b',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'directus-points-shadow',
|
||||
filter: [
|
||||
'all',
|
||||
['==', 'active', 'false'],
|
||||
['==', '$type', 'Point'],
|
||||
['==', 'meta', 'feature'],
|
||||
['!=', 'meta', 'midpoint'],
|
||||
],
|
||||
type: 'circle',
|
||||
paint: {
|
||||
'circle-pitch-alignment': 'map',
|
||||
'circle-blur': 1,
|
||||
'circle-opacity': 0.5,
|
||||
'circle-radius': 6,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'directus-point-inactive',
|
||||
filter: [
|
||||
'all',
|
||||
['==', '$type', 'Point'],
|
||||
['==', 'active', 'false'],
|
||||
['==', 'meta', 'feature'],
|
||||
['!=', 'meta', 'midpoint'],
|
||||
],
|
||||
type: 'symbol',
|
||||
layout: {
|
||||
'icon-image': 'place',
|
||||
'icon-anchor': 'bottom',
|
||||
'icon-allow-overlap': true,
|
||||
'icon-size': 2,
|
||||
'icon-offset': [0, 3],
|
||||
},
|
||||
paint: {
|
||||
'icon-color': '#3bb2d0',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'directus-point-active',
|
||||
filter: [
|
||||
'all',
|
||||
['==', '$type', 'Point'],
|
||||
['==', 'active', 'true'],
|
||||
['==', 'meta', 'feature'],
|
||||
['!=', 'meta', 'midpoint'],
|
||||
],
|
||||
type: 'symbol',
|
||||
layout: {
|
||||
'icon-image': 'place',
|
||||
'icon-anchor': 'bottom',
|
||||
'icon-allow-overlap': true,
|
||||
'icon-size': 2,
|
||||
'icon-offset': [0, 3],
|
||||
},
|
||||
paint: {
|
||||
'icon-color': '#fbb03b',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'directus-point-static',
|
||||
type: 'symbol',
|
||||
filter: [
|
||||
'all',
|
||||
['==', '$type', 'Point'],
|
||||
['==', 'mode', 'static'],
|
||||
['==', 'meta', 'feature'],
|
||||
['!=', 'meta', 'midpoint'],
|
||||
],
|
||||
layout: {
|
||||
'icon-image': 'place',
|
||||
'icon-anchor': 'bottom',
|
||||
'icon-allow-overlap': true,
|
||||
'icon-size': 2,
|
||||
'icon-offset': [0, 3],
|
||||
},
|
||||
paint: {
|
||||
'icon-color': '#404040',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'directus-polygon-fill-static',
|
||||
type: 'fill',
|
||||
filter: ['all', ['==', 'mode', 'static'], ['==', '$type', 'Polygon']],
|
||||
paint: {
|
||||
'fill-color': '#404040',
|
||||
'fill-outline-color': '#404040',
|
||||
'fill-opacity': 0.1,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'directus-polygon-stroke-static',
|
||||
type: 'line',
|
||||
filter: ['all', ['==', 'mode', 'static'], ['==', '$type', 'Polygon']],
|
||||
layout: {
|
||||
'line-cap': 'round',
|
||||
'line-join': 'round',
|
||||
},
|
||||
paint: {
|
||||
'line-color': '#404040',
|
||||
'line-width': 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'directus-line-static',
|
||||
type: 'line',
|
||||
filter: ['all', ['==', 'mode', 'static'], ['==', '$type', 'LineString']],
|
||||
layout: {
|
||||
'line-cap': 'round',
|
||||
'line-join': 'round',
|
||||
},
|
||||
paint: {
|
||||
'line-color': '#404040',
|
||||
'line-width': 2,
|
||||
},
|
||||
},
|
||||
];
|
||||
Reference in New Issue
Block a user