mirror of
https://github.com/directus/directus.git
synced 2026-01-23 03:08:08 -05: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:
@@ -92,7 +92,7 @@
|
||||
"cookie-parser": "^1.4.5",
|
||||
"cors": "^2.8.5",
|
||||
"csv-parser": "^3.0.0",
|
||||
"date-fns": "^2.21.1",
|
||||
"date-fns": "^2.22.1",
|
||||
"deep-map": "^2.0.0",
|
||||
"destroy": "^1.0.4",
|
||||
"dotenv": "^10.0.0",
|
||||
@@ -139,7 +139,8 @@
|
||||
"stream-json": "^1.7.1",
|
||||
"update-check": "^1.5.4",
|
||||
"uuid": "^8.3.2",
|
||||
"uuid-validate": "0.0.3"
|
||||
"uuid-validate": "0.0.3",
|
||||
"wellknown": "^0.5.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@keyv/redis": "^2.1.2",
|
||||
@@ -185,6 +186,7 @@
|
||||
"@types/stream-json": "1.7.1",
|
||||
"@types/uuid": "8.3.1",
|
||||
"@types/uuid-validate": "0.0.1",
|
||||
"@types/wellknown": "^0.5.1",
|
||||
"copyfiles": "2.4.1",
|
||||
"cross-env": "7.0.3",
|
||||
"ts-node-dev": "1.1.8",
|
||||
|
||||
163
api/src/database/helpers/geometry.ts
Normal file
163
api/src/database/helpers/geometry.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { Field, RawField } from '@directus/shared/types';
|
||||
import { Knex } from 'knex';
|
||||
import { stringify as geojsonToWKT, GeoJSONGeometry } from 'wellknown';
|
||||
import getDatabase from '..';
|
||||
|
||||
let geometryHelper: KnexSpatial | undefined;
|
||||
|
||||
export function getGeometryHelper(): KnexSpatial {
|
||||
if (!geometryHelper) {
|
||||
const db = getDatabase();
|
||||
const client = db.client.config.client as string;
|
||||
const constructor = {
|
||||
mysql: KnexSpatial_MySQL,
|
||||
mariadb: KnexSpatial_MySQL,
|
||||
sqlite3: KnexSpatial,
|
||||
pg: KnexSpatial_PG,
|
||||
redshift: KnexSpatial_Redshift,
|
||||
mssql: KnexSpatial_MSSQL,
|
||||
oracledb: KnexSpatial_Oracle,
|
||||
}[client];
|
||||
if (!constructor) {
|
||||
throw new Error(`Geometry helper not implemented on ${client}.`);
|
||||
}
|
||||
geometryHelper = new constructor(db);
|
||||
}
|
||||
return geometryHelper;
|
||||
}
|
||||
|
||||
class KnexSpatial {
|
||||
constructor(protected knex: Knex) {}
|
||||
isTrue(expression: Knex.Raw) {
|
||||
return expression;
|
||||
}
|
||||
isFalse(expression: Knex.Raw) {
|
||||
return expression.wrap('NOT ', '');
|
||||
}
|
||||
createColumn(table: Knex.CreateTableBuilder, field: RawField | Field) {
|
||||
const type = field.schema?.geometry_type ?? 'geometry';
|
||||
return table.specificType(field.field, type);
|
||||
}
|
||||
asText(table: string, column: string): Knex.Raw {
|
||||
return this.knex.raw('st_astext(??.??) as ??', [table, column, column]);
|
||||
}
|
||||
fromText(text: string): Knex.Raw {
|
||||
return this.knex.raw('st_geomfromtext(?, 4326)', text);
|
||||
}
|
||||
fromGeoJSON(geojson: GeoJSONGeometry): Knex.Raw {
|
||||
return this.fromText(geojsonToWKT(geojson));
|
||||
}
|
||||
_intersects(key: string, geojson: GeoJSONGeometry): Knex.Raw {
|
||||
const geometry = this.fromGeoJSON(geojson);
|
||||
return this.knex.raw('st_intersects(??, ?)', [key, geometry]);
|
||||
}
|
||||
intersects(key: string, geojson: GeoJSONGeometry): Knex.Raw {
|
||||
return this.isTrue(this._intersects(key, geojson));
|
||||
}
|
||||
nintersects(key: string, geojson: GeoJSONGeometry): Knex.Raw {
|
||||
return this.isFalse(this._intersects(key, geojson));
|
||||
}
|
||||
_intersects_bbox(key: string, geojson: GeoJSONGeometry): Knex.Raw {
|
||||
const geometry = this.fromGeoJSON(geojson);
|
||||
return this.knex.raw('intersects(??, ?)', [key, geometry]);
|
||||
}
|
||||
intersects_bbox(key: string, geojson: GeoJSONGeometry): Knex.Raw {
|
||||
return this.isTrue(this._intersects_bbox(key, geojson));
|
||||
}
|
||||
nintersects_bbox(key: string, geojson: GeoJSONGeometry): Knex.Raw {
|
||||
return this.isFalse(this._intersects_bbox(key, geojson));
|
||||
}
|
||||
collect(table: string, column: string): Knex.Raw {
|
||||
return this.knex.raw('st_astext(st_collect(??.??))', [table, column]);
|
||||
}
|
||||
}
|
||||
|
||||
class KnexSpatial_PG extends KnexSpatial {
|
||||
createColumn(table: Knex.CreateTableBuilder, field: RawField | Field) {
|
||||
const type = field.schema?.geometry_type ?? 'geometry';
|
||||
return table.specificType(field.field, `geometry(${type})`);
|
||||
}
|
||||
_intersects_bbox(key: string, geojson: GeoJSONGeometry): Knex.Raw {
|
||||
const geometry = this.fromGeoJSON(geojson);
|
||||
return this.knex.raw('?? && ?', [key, geometry]);
|
||||
}
|
||||
}
|
||||
|
||||
class KnexSpatial_MySQL extends KnexSpatial {
|
||||
collect(table: string, column: string): Knex.Raw {
|
||||
return this.knex.raw(
|
||||
`concat('geometrycollection(', group_concat(? separator ', '), ')'`,
|
||||
this.asText(table, column)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class KnexSpatial_Redshift extends KnexSpatial {
|
||||
createColumn(table: Knex.CreateTableBuilder, field: RawField | Field) {
|
||||
const type = field.schema?.geometry_type ?? 'geometry';
|
||||
if (type !== 'geometry') field.meta!.special![1] = type;
|
||||
return table.specificType(field.field, 'geometry');
|
||||
}
|
||||
}
|
||||
|
||||
class KnexSpatial_MSSQL extends KnexSpatial {
|
||||
isTrue(expression: Knex.Raw) {
|
||||
return expression.wrap(``, ` = 1`);
|
||||
}
|
||||
isFalse(expression: Knex.Raw) {
|
||||
return expression.wrap(``, ` = 0`);
|
||||
}
|
||||
createColumn(table: Knex.CreateTableBuilder, field: RawField | Field) {
|
||||
const type = field.schema?.geometry_type ?? 'geometry';
|
||||
if (type !== 'geometry') field.meta!.special![1] = type;
|
||||
return table.specificType(field.field, 'geometry');
|
||||
}
|
||||
asText(table: string, column: string): Knex.Raw {
|
||||
return this.knex.raw('??.??.STAsText() as ??', [table, column, column]);
|
||||
}
|
||||
fromText(text: string): Knex.Raw {
|
||||
return this.knex.raw('geometry::STGeomFromText(?, 4326)', text);
|
||||
}
|
||||
_intersects(key: string, geojson: GeoJSONGeometry): Knex.Raw {
|
||||
const geometry = this.fromGeoJSON(geojson);
|
||||
return this.knex.raw('??.STIntersects(?)', [key, geometry]);
|
||||
}
|
||||
_intersects_bbox(key: string, geojson: GeoJSONGeometry): Knex.Raw {
|
||||
const geometry = this.fromGeoJSON(geojson);
|
||||
return this.knex.raw('??.STEnvelope().STIntersects(?.STEnvelope())', [key, geometry]);
|
||||
}
|
||||
collect(table: string, column: string): Knex.Raw {
|
||||
return this.knex.raw('geometry::CollectionAggregate(??.??).STAsText()', [table, column]);
|
||||
}
|
||||
}
|
||||
|
||||
class KnexSpatial_Oracle extends KnexSpatial {
|
||||
isTrue(expression: Knex.Raw) {
|
||||
return expression.wrap(``, ` = 'TRUE'`);
|
||||
}
|
||||
isFalse(expression: Knex.Raw) {
|
||||
return expression.wrap(``, ` = 'FALSE'`);
|
||||
}
|
||||
createColumn(table: Knex.CreateTableBuilder, field: RawField | Field) {
|
||||
const type = field.schema?.geometry_type ?? 'geometry';
|
||||
if (type !== 'geometry') field.meta!.special![1] = type;
|
||||
return table.specificType(field.field, 'sdo_geometry');
|
||||
}
|
||||
asText(table: string, column: string): Knex.Raw {
|
||||
return this.knex.raw('sdo_util.from_wktgeometry(??.??) as ??', [table, column, column]);
|
||||
}
|
||||
fromText(text: string): Knex.Raw {
|
||||
return this.knex.raw('sdo_geometry(?, 4326)', text);
|
||||
}
|
||||
_intersects(key: string, geojson: GeoJSONGeometry): Knex.Raw {
|
||||
const geometry = this.fromGeoJSON(geojson);
|
||||
return this.knex.raw(`sdo_overlapbdyintersect(??, ?)`, [key, geometry]);
|
||||
}
|
||||
_intersects_bbox(key: string, geojson: GeoJSONGeometry): Knex.Raw {
|
||||
const geometry = this.fromGeoJSON(geojson);
|
||||
return this.knex.raw(`sdo_overlapbdyintersect(sdo_geom.sdo_mbr(??), sdo_geom.sdo_mbr(?))`, [key, geometry]);
|
||||
}
|
||||
collect(table: string, column: string): Knex.Raw {
|
||||
return this.knex.raw(`concat('geometrycollection(', listagg(?, ', '), ')'`, this.asText(table, column));
|
||||
}
|
||||
}
|
||||
15
api/src/database/migrations/20210811A-add-geometry-config.ts
Normal file
15
api/src/database/migrations/20210811A-add-geometry-config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Knex } from 'knex';
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
await knex.schema.alterTable('directus_settings', (table) => {
|
||||
table.json('basemaps');
|
||||
table.string('mapbox_key');
|
||||
});
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
await knex.schema.alterTable('directus_settings', (table) => {
|
||||
table.dropColumn('basemaps');
|
||||
table.dropColumn('mapbox_key');
|
||||
});
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import { AST, FieldNode, NestedCollectionNode } from '../types/ast';
|
||||
import applyQuery from '../utils/apply-query';
|
||||
import { toArray } from '@directus/shared/utils';
|
||||
import getDatabase from './index';
|
||||
import { isNativeGeometry } from '../utils/geometry';
|
||||
import { getGeometryHelper } from '../database/helpers/geometry';
|
||||
|
||||
type RunASTOptions = {
|
||||
/**
|
||||
@@ -143,6 +145,17 @@ async function parseCurrentLevel(
|
||||
return { columnsToSelect, nestedCollectionNodes, primaryKeyField };
|
||||
}
|
||||
|
||||
function getColumnPreprocessor(knex: Knex, schema: SchemaOverview, table: string) {
|
||||
const helper = getGeometryHelper();
|
||||
return function (column: string): Knex.Raw<string> {
|
||||
const field = schema.collections[table].fields[column];
|
||||
if (isNativeGeometry(field)) {
|
||||
return helper.asText(table, column);
|
||||
}
|
||||
return knex.raw('??.??', [table, column]);
|
||||
};
|
||||
}
|
||||
|
||||
function getDBQuery(
|
||||
schema: SchemaOverview,
|
||||
knex: Knex,
|
||||
@@ -151,8 +164,8 @@ function getDBQuery(
|
||||
query: Query,
|
||||
nested?: boolean
|
||||
): Knex.QueryBuilder {
|
||||
const dbQuery = knex.select(columns.map((column) => `${table}.${column}`)).from(table);
|
||||
|
||||
const preProcess = getColumnPreprocessor(knex, schema, table);
|
||||
const dbQuery = knex.select(columns.map(preProcess)).from(table);
|
||||
const queryCopy = clone(query);
|
||||
|
||||
queryCopy.limit = typeof queryCopy.limit === 'number' ? queryCopy.limit : 100;
|
||||
|
||||
@@ -3,7 +3,8 @@ import yaml from 'js-yaml';
|
||||
import { Knex } from 'knex';
|
||||
import { isObject } from 'lodash';
|
||||
import path from 'path';
|
||||
import { Type } from '@directus/shared/types';
|
||||
import { Type, Field } from '@directus/shared/types';
|
||||
import { getGeometryHelper } from '../helpers/geometry';
|
||||
|
||||
type TableSeed = {
|
||||
table: string;
|
||||
@@ -55,6 +56,9 @@ export default async function runSeed(database: Knex): Promise<void> {
|
||||
column = tableBuilder.string(columnName);
|
||||
} else if (columnInfo.type === 'hash') {
|
||||
column = tableBuilder.string(columnName, 255);
|
||||
} else if (columnInfo.type === 'geometry') {
|
||||
const helper = getGeometryHelper();
|
||||
column = helper.createColumn(tableBuilder, { field: columnName } as Field);
|
||||
} else {
|
||||
column = tableBuilder[columnInfo.type!](columnName);
|
||||
}
|
||||
|
||||
@@ -264,3 +264,58 @@ fields:
|
||||
language: css
|
||||
lineNumber: true
|
||||
width: full
|
||||
|
||||
- field: map_divider
|
||||
interface: presentation-divider
|
||||
options:
|
||||
icon: map
|
||||
title: $t:maps
|
||||
special:
|
||||
- alias
|
||||
- no-data
|
||||
width: full
|
||||
|
||||
- field: mapbox_key
|
||||
interface: input
|
||||
options:
|
||||
icon: key
|
||||
title: Mapbox Access Token
|
||||
placeholder: pk.eyJ1Ijo.....
|
||||
iconLeft: vpn_key
|
||||
font: monospace
|
||||
width: half
|
||||
|
||||
- field: basemaps
|
||||
interface: list
|
||||
special: json
|
||||
options:
|
||||
template: '{{name}}'
|
||||
fields:
|
||||
- field: name
|
||||
name: $t:name
|
||||
schema:
|
||||
is_nullable: false
|
||||
meta:
|
||||
interface: text-input
|
||||
options:
|
||||
placeholder: Enter the basemap name...
|
||||
- field: type
|
||||
name: $t:type
|
||||
meta:
|
||||
interface: select-dropdown
|
||||
options:
|
||||
choices:
|
||||
- value: raster
|
||||
text: Raster
|
||||
- value: tile
|
||||
text: Raster TileJSON
|
||||
- value: style
|
||||
text: Mapbox Style
|
||||
- field: url
|
||||
name: $t:url
|
||||
schema:
|
||||
is_nullable: false
|
||||
meta:
|
||||
interface: text-input
|
||||
options:
|
||||
placeholder: http://{a-c}.tile.openstreetmap.org/{z}/{x}/{y}.png
|
||||
|
||||
@@ -7,11 +7,11 @@ import { systemCollectionRows } from '../database/system-data/collections';
|
||||
import env from '../env';
|
||||
import { ForbiddenException, InvalidPayloadException } from '../exceptions';
|
||||
import logger from '../logger';
|
||||
import { FieldsService, RawField } from '../services/fields';
|
||||
import { FieldsService } from '../services/fields';
|
||||
import { ItemsService, MutationOptions } from '../services/items';
|
||||
import Keyv from 'keyv';
|
||||
import { AbstractServiceOptions, Collection, CollectionMeta, SchemaOverview } from '../types';
|
||||
import { Accountability, FieldMeta } from '@directus/shared/types';
|
||||
import { Accountability, FieldMeta, RawField } from '@directus/shared/types';
|
||||
|
||||
export type RawCollection = {
|
||||
collection: string;
|
||||
|
||||
@@ -13,16 +13,14 @@ import { ItemsService } from '../services/items';
|
||||
import { PayloadService } from '../services/payload';
|
||||
import { AbstractServiceOptions, SchemaOverview } from '../types';
|
||||
import { Accountability } from '@directus/shared/types';
|
||||
import { Field, FieldMeta, Type } from '@directus/shared/types';
|
||||
import { Field, FieldMeta, RawField, Type } from '@directus/shared/types';
|
||||
import getDefaultValue from '../utils/get-default-value';
|
||||
import getLocalType from '../utils/get-local-type';
|
||||
import { toArray } from '@directus/shared/utils';
|
||||
import { isEqual, isNil } from 'lodash';
|
||||
import { RelationsService } from './relations';
|
||||
import { getGeometryHelper } from '../database/helpers/geometry';
|
||||
import Keyv from 'keyv';
|
||||
import { DeepPartial } from '@directus/shared/types';
|
||||
|
||||
export type RawField = DeepPartial<Field> & { field: string; type: Type };
|
||||
|
||||
export class FieldsService {
|
||||
knex: Knex;
|
||||
@@ -77,25 +75,22 @@ export class FieldsService {
|
||||
fields.push(...systemFieldRows);
|
||||
}
|
||||
|
||||
let columns = await this.schemaInspector.columnInfo(collection);
|
||||
|
||||
columns = columns.map((column) => {
|
||||
return {
|
||||
...column,
|
||||
default_value: getDefaultValue(column),
|
||||
};
|
||||
});
|
||||
const columns = (await this.schemaInspector.columnInfo(collection)).map((column) => ({
|
||||
...column,
|
||||
default_value: getDefaultValue(column),
|
||||
}));
|
||||
|
||||
const columnsWithSystem = columns.map((column) => {
|
||||
const field = fields.find((field) => {
|
||||
return field.field === column.name && field.collection === column.table;
|
||||
});
|
||||
|
||||
const { type = 'alias', ...info } = column ? getLocalType(column, field) : {};
|
||||
const data = {
|
||||
collection: column.table,
|
||||
field: column.name,
|
||||
type: column ? getLocalType(column, field) : 'alias',
|
||||
schema: column,
|
||||
type: type,
|
||||
schema: { ...column, ...info },
|
||||
meta: field || null,
|
||||
};
|
||||
|
||||
@@ -202,12 +197,13 @@ export class FieldsService {
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
const { type = 'alias', ...info } = column ? getLocalType(column, fieldInfo) : {};
|
||||
const data = {
|
||||
collection,
|
||||
field,
|
||||
type: column ? getLocalType(column, fieldInfo) : 'alias',
|
||||
type,
|
||||
meta: fieldInfo || null,
|
||||
schema: column || null,
|
||||
schema: type == 'alias' ? null : { ...column, ...info },
|
||||
};
|
||||
|
||||
return data;
|
||||
@@ -458,6 +454,9 @@ export class FieldsService {
|
||||
column = table.dateTime(field.field, { useTz: false });
|
||||
} else if (field.type === 'timestamp') {
|
||||
column = table.timestamp(field.field, { useTz: true });
|
||||
} else if (field.type === 'geometry') {
|
||||
const helper = getGeometryHelper();
|
||||
column = helper.createColumn(table, field);
|
||||
} else {
|
||||
column = table[field.type](field.field);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import argon2 from 'argon2';
|
||||
import { validateQuery } from '../utils/validate-query';
|
||||
import {
|
||||
ArgumentNode,
|
||||
BooleanValueNode,
|
||||
@@ -92,6 +93,12 @@ const GraphQLVoid = new GraphQLScalarType({
|
||||
},
|
||||
});
|
||||
|
||||
export const GraphQLGeoJSON = new GraphQLScalarType({
|
||||
...GraphQLJSON,
|
||||
name: 'GraphQLGeoJSON',
|
||||
description: 'GeoJSON value',
|
||||
});
|
||||
|
||||
export const GraphQLDate = new GraphQLScalarType({
|
||||
...GraphQLString,
|
||||
name: 'Date',
|
||||
@@ -536,6 +543,30 @@ export class GraphQLService {
|
||||
},
|
||||
});
|
||||
|
||||
const GeometryFilterOperators = schemaComposer.createInputTC({
|
||||
name: 'geometry_filter_operators',
|
||||
fields: {
|
||||
_eq: {
|
||||
type: GraphQLGeoJSON,
|
||||
},
|
||||
_neq: {
|
||||
type: GraphQLGeoJSON,
|
||||
},
|
||||
_intersects: {
|
||||
type: GraphQLGeoJSON,
|
||||
},
|
||||
_nintersects: {
|
||||
type: GraphQLGeoJSON,
|
||||
},
|
||||
_intersects_bbox: {
|
||||
type: GraphQLGeoJSON,
|
||||
},
|
||||
_nintersects_bbox: {
|
||||
type: GraphQLGeoJSON,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
for (const collection of Object.values(schema.read.collections)) {
|
||||
if (Object.keys(collection.fields).length === 0) continue;
|
||||
if (SYSTEM_DENY_LIST.includes(collection.collection)) continue;
|
||||
@@ -557,6 +588,9 @@ export class GraphQLService {
|
||||
case GraphQLDate:
|
||||
filterOperatorType = DateFilterOperators;
|
||||
break;
|
||||
case GraphQLGeoJSON:
|
||||
filterOperatorType = GeometryFilterOperators;
|
||||
break;
|
||||
default:
|
||||
filterOperatorType = StringFilterOperators;
|
||||
}
|
||||
@@ -1088,6 +1122,8 @@ export class GraphQLService {
|
||||
|
||||
query.fields = parseFields(selections);
|
||||
|
||||
validateQuery(query);
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
|
||||
@@ -172,8 +172,8 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
|
||||
activity: activityID,
|
||||
collection: this.collection,
|
||||
item: primaryKey,
|
||||
data: JSON.stringify(payload),
|
||||
delta: JSON.stringify(payload),
|
||||
data: await payloadService.prepareDelta(payload),
|
||||
delta: await payloadService.prepareDelta(payload),
|
||||
};
|
||||
|
||||
const revisionID = (await trx.insert(revisionRecord).into('directus_revisions').returning('id'))[0] as number;
|
||||
@@ -480,14 +480,23 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
|
||||
|
||||
const snapshots = await itemsService.readMany(keys);
|
||||
|
||||
const revisionRecords = activityPrimaryKeys.map((key, index) => ({
|
||||
activity: key,
|
||||
collection: this.collection,
|
||||
item: keys[index],
|
||||
data:
|
||||
snapshots && Array.isArray(snapshots) ? JSON.stringify(snapshots?.[index]) : JSON.stringify(snapshots),
|
||||
delta: JSON.stringify(payloadWithTypeCasting),
|
||||
}));
|
||||
const revisionRecords: {
|
||||
activity: PrimaryKey;
|
||||
collection: string;
|
||||
item: PrimaryKey;
|
||||
data: string;
|
||||
delta: string;
|
||||
}[] = [];
|
||||
|
||||
for (let i = 0; i < activityPrimaryKeys.length; i++) {
|
||||
revisionRecords.push({
|
||||
activity: activityPrimaryKeys[i],
|
||||
collection: this.collection,
|
||||
item: keys[i],
|
||||
data: snapshots && Array.isArray(snapshots) ? JSON.stringify(snapshots[i]) : JSON.stringify(snapshots),
|
||||
delta: await payloadService.prepareDelta(payloadWithTypeCasting),
|
||||
});
|
||||
}
|
||||
|
||||
for (let i = 0; i < revisionRecords.length; i++) {
|
||||
const revisionID = (
|
||||
|
||||
@@ -10,6 +10,9 @@ import { AbstractServiceOptions, Item, PrimaryKey, Query, SchemaOverview, Altera
|
||||
import { Accountability } from '@directus/shared/types';
|
||||
import { toArray } from '@directus/shared/utils';
|
||||
import { ItemsService } from './items';
|
||||
import { isNativeGeometry } from '../utils/geometry';
|
||||
import { getGeometryHelper } from '../database/helpers/geometry';
|
||||
import { parse as wktToGeoJSON } from 'wellknown';
|
||||
|
||||
type Action = 'create' | 'read' | 'update';
|
||||
|
||||
@@ -19,6 +22,7 @@ type Transformers = {
|
||||
value: any;
|
||||
payload: Partial<Item>;
|
||||
accountability: Accountability | null;
|
||||
specials: string[];
|
||||
}) => Promise<any>;
|
||||
};
|
||||
|
||||
@@ -148,13 +152,16 @@ export class PayloadService {
|
||||
})
|
||||
);
|
||||
|
||||
await this.processDates(processedPayload, action);
|
||||
this.processGeometries(processedPayload, action);
|
||||
this.processDates(processedPayload, action);
|
||||
|
||||
if (['create', 'update'].includes(action)) {
|
||||
processedPayload.forEach((record) => {
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
if (Array.isArray(value) || (typeof value === 'object' && value instanceof Date !== true && value !== null)) {
|
||||
record[key] = JSON.stringify(value);
|
||||
if (Array.isArray(value) || (typeof value === 'object' && !(value instanceof Date) && value !== null)) {
|
||||
if (!value.isRawInstance) {
|
||||
record[key] = JSON.stringify(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -185,6 +192,7 @@ export class PayloadService {
|
||||
value,
|
||||
payload,
|
||||
accountability,
|
||||
specials: fieldSpecials,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -192,14 +200,40 @@ export class PayloadService {
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Native geometries are stored in custom binary format. We need to insert them with
|
||||
* the function st_geomfromtext. For this to work, that function call must not be
|
||||
* escaped. It's therefore placed as a Knex.Raw object in the payload. Thus the need
|
||||
* to check if the value is a raw instance before stringifying it in the next step.
|
||||
*/
|
||||
processGeometries<T extends Partial<Record<string, any>>[]>(payloads: T, action: Action): T {
|
||||
const helper = getGeometryHelper();
|
||||
|
||||
const process =
|
||||
action == 'read'
|
||||
? (value: any) => {
|
||||
if (typeof value === 'string') return wktToGeoJSON(value);
|
||||
}
|
||||
: (value: any) => helper.fromGeoJSON(typeof value == 'string' ? JSON.parse(value) : value);
|
||||
|
||||
const fieldsInCollection = Object.entries(this.schema.collections[this.collection].fields);
|
||||
const geometryColumns = fieldsInCollection.filter(([_, field]) => isNativeGeometry(field));
|
||||
|
||||
for (const [name] of geometryColumns) {
|
||||
for (const payload of payloads) {
|
||||
if (payload[name]) {
|
||||
payload[name] = process(payload[name]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return payloads;
|
||||
}
|
||||
/**
|
||||
* Knex returns `datetime` and `date` columns as Date.. This is wrong for date / datetime, as those
|
||||
* shouldn't return with time / timezone info respectively
|
||||
*/
|
||||
async processDates(
|
||||
payloads: Partial<Record<string, any>>[],
|
||||
action: Action
|
||||
): Promise<Partial<Record<string, any>>[]> {
|
||||
processDates(payloads: Partial<Record<string, any>>[], action: Action): Partial<Record<string, any>>[] {
|
||||
const fieldsInCollection = Object.entries(this.schema.collections[this.collection].fields);
|
||||
|
||||
const dateColumns = fieldsInCollection.filter(([_name, field]) =>
|
||||
@@ -638,4 +672,22 @@ export class PayloadService {
|
||||
|
||||
return { revisions };
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms the input partial payload to match the output structure, to have consistency
|
||||
* between delta and data
|
||||
*/
|
||||
async prepareDelta(data: Partial<Item>): Promise<string> {
|
||||
let payload = cloneDeep(data);
|
||||
|
||||
for (const key in payload) {
|
||||
if (payload[key]?.isRawInstance) {
|
||||
payload[key] = payload[key].bindings[0];
|
||||
}
|
||||
}
|
||||
|
||||
payload = await this.processValues('read', payload);
|
||||
|
||||
return JSON.stringify(payload);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -526,6 +526,9 @@ class OASSpecsService implements SpecificationSubService {
|
||||
type: 'string',
|
||||
format: 'uuid',
|
||||
},
|
||||
geometry: {
|
||||
type: 'string',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -21,19 +21,3 @@ export type Sort = {
|
||||
export type Filter = {
|
||||
[keyOrOperator: string]: Filter | any;
|
||||
};
|
||||
|
||||
export type FilterOperator =
|
||||
| 'eq'
|
||||
| 'neq'
|
||||
| 'contains'
|
||||
| 'ncontains'
|
||||
| 'in'
|
||||
| 'nin'
|
||||
| 'gt'
|
||||
| 'gte'
|
||||
| 'lt'
|
||||
| 'lte'
|
||||
| 'null'
|
||||
| 'nnull'
|
||||
| 'empty'
|
||||
| 'nempty';
|
||||
|
||||
@@ -2,7 +2,20 @@ import { Type } from '@directus/shared/types';
|
||||
import { Permission } from './permissions';
|
||||
import { Relation } from './relation';
|
||||
|
||||
type CollectionsOverview = {
|
||||
export type FieldOverview = {
|
||||
field: string;
|
||||
defaultValue: any;
|
||||
nullable: boolean;
|
||||
type: Type | 'unknown' | 'alias';
|
||||
dbType: string | null;
|
||||
precision: number | null;
|
||||
scale: number | null;
|
||||
special: string[];
|
||||
note: string | null;
|
||||
alias: boolean;
|
||||
};
|
||||
|
||||
export type CollectionsOverview = {
|
||||
[name: string]: {
|
||||
collection: string;
|
||||
primary: string;
|
||||
@@ -11,18 +24,7 @@ type CollectionsOverview = {
|
||||
note: string | null;
|
||||
accountability: 'all' | 'activity' | null;
|
||||
fields: {
|
||||
[name: string]: {
|
||||
field: string;
|
||||
defaultValue: any;
|
||||
nullable: boolean;
|
||||
type: Type | 'unknown' | 'alias';
|
||||
dbType: string | null;
|
||||
precision: number | null;
|
||||
scale: number | null;
|
||||
special: string[];
|
||||
note: string | null;
|
||||
alias: boolean;
|
||||
};
|
||||
[name: string]: FieldOverview;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import validate from 'uuid-validate';
|
||||
import { InvalidQueryException } from '../exceptions';
|
||||
import { Filter, Query, Relation, SchemaOverview } from '../types';
|
||||
import { getRelationType } from './get-relation-type';
|
||||
import { getGeometryHelper } from '../database/helpers/geometry';
|
||||
|
||||
const generateAlias = customAlphabet('abcdefghijklmnopqrstuvwxyz', 5);
|
||||
|
||||
@@ -86,6 +87,7 @@ export default function applyQuery(
|
||||
* )
|
||||
* ```
|
||||
*/
|
||||
|
||||
export function applyFilter(
|
||||
schema: SchemaOverview,
|
||||
rootQuery: Knex.QueryBuilder,
|
||||
@@ -403,6 +405,23 @@ export function applyFilter(
|
||||
|
||||
dbQuery[logical].whereNotBetween(key, value);
|
||||
}
|
||||
|
||||
const geometryHelper = getGeometryHelper();
|
||||
|
||||
if (operator == '_intersects') {
|
||||
dbQuery[logical].whereRaw(geometryHelper.intersects(key, compareValue));
|
||||
}
|
||||
|
||||
if (operator == '_nintersects') {
|
||||
dbQuery[logical].whereRaw(geometryHelper.nintersects(key, compareValue));
|
||||
}
|
||||
if (operator == '_intersects_bbox') {
|
||||
dbQuery[logical].whereRaw(geometryHelper.intersects_bbox(key, compareValue));
|
||||
}
|
||||
|
||||
if (operator == '_nintersects_bbox') {
|
||||
dbQuery[logical].whereRaw(geometryHelper.nintersects_bbox(key, compareValue));
|
||||
}
|
||||
}
|
||||
|
||||
function getWhereColumn(path: string[], collection: string) {
|
||||
|
||||
18
api/src/utils/geometry.ts
Normal file
18
api/src/utils/geometry.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { FieldOverview } from '../types';
|
||||
const dbGeometricTypes = new Set([
|
||||
'point',
|
||||
'polygon',
|
||||
'linestring',
|
||||
'multipoint',
|
||||
'multipolygon',
|
||||
'multilinestring',
|
||||
'geometry',
|
||||
'geometrycollection',
|
||||
'sdo_geometry',
|
||||
'user-defined',
|
||||
]);
|
||||
|
||||
export function isNativeGeometry(field: FieldOverview): boolean {
|
||||
const { type, dbType } = field;
|
||||
return type == 'geometry' && dbGeometricTypes.has(dbType!.toLowerCase());
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import getLocalType from './get-local-type';
|
||||
export default function getDefaultValue(
|
||||
column: SchemaOverview[string]['columns'][string] | Column
|
||||
): string | boolean | null {
|
||||
const type = getLocalType(column);
|
||||
const { type } = getLocalType(column);
|
||||
|
||||
let defaultValue = column.default_value ?? null;
|
||||
if (defaultValue === null) return null;
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
GraphQLType,
|
||||
} from 'graphql';
|
||||
import { GraphQLJSON } from 'graphql-compose';
|
||||
import { GraphQLDate } from '../services/graphql';
|
||||
import { GraphQLDate, GraphQLGeoJSON } from '../services/graphql';
|
||||
import { Type } from '@directus/shared/types';
|
||||
|
||||
export function getGraphQLType(localType: Type | 'alias' | 'unknown'): GraphQLScalarType | GraphQLList<GraphQLType> {
|
||||
@@ -25,6 +25,8 @@ export function getGraphQLType(localType: Type | 'alias' | 'unknown'): GraphQLSc
|
||||
return new GraphQLList(GraphQLString);
|
||||
case 'json':
|
||||
return GraphQLJSON;
|
||||
case 'geometry':
|
||||
return GraphQLGeoJSON;
|
||||
case 'timestamp':
|
||||
case 'dateTime':
|
||||
case 'date':
|
||||
|
||||
@@ -3,7 +3,12 @@ import { Column } from 'knex-schema-inspector/dist/types/column';
|
||||
import { FieldMeta, Type } from '@directus/shared/types';
|
||||
import getDatabase from '../database';
|
||||
|
||||
const localTypeMap: Record<string, { type: Type; useTimezone?: boolean }> = {
|
||||
type LocalTypeEntry = {
|
||||
type: Type | 'unknown';
|
||||
geometry_type?: 'Point' | 'LineString' | 'Polygon' | 'MultiPoint' | 'MultiLineString' | 'MultiPolygon';
|
||||
};
|
||||
|
||||
const localTypeMap: Record<string, LocalTypeEntry> = {
|
||||
// Shared
|
||||
boolean: { type: 'boolean' },
|
||||
tinyint: { type: 'integer' },
|
||||
@@ -38,6 +43,15 @@ const localTypeMap: Record<string, { type: Type; useTimezone?: boolean }> = {
|
||||
decimal: { type: 'decimal' },
|
||||
numeric: { type: 'integer' },
|
||||
|
||||
// Geometries
|
||||
point: { type: 'geometry', geometry_type: 'Point' },
|
||||
linestring: { type: 'geometry', geometry_type: 'LineString' },
|
||||
polygon: { type: 'geometry', geometry_type: 'Polygon' },
|
||||
multipoint: { type: 'geometry', geometry_type: 'MultiPoint' },
|
||||
multilinestring: { type: 'geometry', geometry_type: 'MultiLineString' },
|
||||
multipolygon: { type: 'geometry', geometry_type: 'MultiPolygon' },
|
||||
geometry: { type: 'geometry' },
|
||||
|
||||
// MySQL
|
||||
string: { type: 'text' },
|
||||
year: { type: 'integer' },
|
||||
@@ -49,7 +63,7 @@ const localTypeMap: Record<string, { type: Type; useTimezone?: boolean }> = {
|
||||
bit: { type: 'boolean' },
|
||||
smallmoney: { type: 'float' },
|
||||
money: { type: 'float' },
|
||||
datetimeoffset: { type: 'timestamp', useTimezone: true },
|
||||
datetimeoffset: { type: 'timestamp' },
|
||||
datetime2: { type: 'dateTime' },
|
||||
smalldatetime: { type: 'dateTime' },
|
||||
nchar: { type: 'text' },
|
||||
@@ -73,16 +87,17 @@ const localTypeMap: Record<string, { type: Type; useTimezone?: boolean }> = {
|
||||
_varchar: { type: 'string' },
|
||||
bpchar: { type: 'string' },
|
||||
timestamptz: { type: 'timestamp' },
|
||||
'timestamp with time zone': { type: 'timestamp', useTimezone: true },
|
||||
'timestamp with time zone': { type: 'timestamp' },
|
||||
'timestamp without time zone': { type: 'dateTime' },
|
||||
timetz: { type: 'time' },
|
||||
'time with time zone': { type: 'time', useTimezone: true },
|
||||
'time with time zone': { type: 'time' },
|
||||
'time without time zone': { type: 'time' },
|
||||
float4: { type: 'float' },
|
||||
float8: { type: 'float' },
|
||||
|
||||
// Oracle
|
||||
number: { type: 'integer' },
|
||||
sdo_geometry: { type: 'geometry' },
|
||||
|
||||
// SQLite
|
||||
integerfirst: { type: 'integer' },
|
||||
@@ -91,7 +106,7 @@ const localTypeMap: Record<string, { type: Type; useTimezone?: boolean }> = {
|
||||
export default function getLocalType(
|
||||
column: SchemaOverview[string]['columns'][string] | Column,
|
||||
field?: { special?: FieldMeta['special'] }
|
||||
): Type | 'unknown' {
|
||||
): LocalTypeEntry {
|
||||
const database = getDatabase();
|
||||
|
||||
const type = localTypeMap[column.data_type.toLowerCase().split('(')[0]];
|
||||
@@ -99,10 +114,13 @@ export default function getLocalType(
|
||||
const special = field?.special;
|
||||
|
||||
if (special) {
|
||||
if (special.includes('json')) return 'json';
|
||||
if (special.includes('hash')) return 'hash';
|
||||
if (special.includes('csv')) return 'csv';
|
||||
if (special.includes('uuid')) return 'uuid';
|
||||
if (special.includes('json')) return { type: 'json' };
|
||||
if (special.includes('hash')) return { type: 'hash' };
|
||||
if (special.includes('csv')) return { type: 'csv' };
|
||||
if (special.includes('uuid')) return { type: 'uuid' };
|
||||
if (type?.type == 'geometry' && !type.geometry_type) {
|
||||
type.geometry_type = special[1] as any;
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle OracleDB timestamp with time zone */
|
||||
@@ -111,30 +129,30 @@ export default function getLocalType(
|
||||
|
||||
if (type.startsWith('timestamp')) {
|
||||
if (type.endsWith('with local time zone')) {
|
||||
return 'timestamp';
|
||||
return { type: 'timestamp' };
|
||||
} else {
|
||||
return 'dateTime';
|
||||
return { type: 'dateTime' };
|
||||
}
|
||||
}
|
||||
}
|
||||
/** Handle Postgres numeric decimals */
|
||||
if (column.data_type === 'numeric' && column.numeric_precision !== null && column.numeric_scale !== null) {
|
||||
return 'decimal';
|
||||
return { type: 'decimal' };
|
||||
}
|
||||
|
||||
/** Handle MS SQL varchar(MAX) (eg TEXT) types */
|
||||
if (column.data_type === 'nvarchar' && column.max_length === -1) {
|
||||
return 'text';
|
||||
return { type: 'text' };
|
||||
}
|
||||
|
||||
/** Handle Boolean as TINYINT*/
|
||||
if (column.data_type.toLowerCase() === 'tinyint(1)' || column.data_type.toLowerCase() === 'tinyint(0)') {
|
||||
return 'boolean';
|
||||
return { type: 'boolean' };
|
||||
}
|
||||
|
||||
if (type) {
|
||||
return type.type;
|
||||
return type;
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
return { type: 'unknown' };
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@ async function getDatabaseSchema(
|
||||
field: column.column_name,
|
||||
defaultValue: getDefaultValue(column) ?? null,
|
||||
nullable: column.is_nullable ?? true,
|
||||
type: getLocalType(column) || 'alias',
|
||||
type: column ? getLocalType(column).type : ('alias' as const),
|
||||
dbType: column.data_type,
|
||||
precision: column.numeric_precision || null,
|
||||
scale: column.numeric_scale || null,
|
||||
@@ -168,21 +168,19 @@ async function getDatabaseSchema(
|
||||
if (!result.collections[field.collection]) continue;
|
||||
|
||||
const existing = result.collections[field.collection].fields[field.field];
|
||||
const column = schemaOverview[field.collection].columns[field.field];
|
||||
const special = field.special ? toArray(field.special) : [];
|
||||
const { type = 'alias' } = existing && column ? getLocalType(column, { special }) : {};
|
||||
|
||||
result.collections[field.collection].fields[field.field] = {
|
||||
field: field.field,
|
||||
defaultValue: existing?.defaultValue ?? null,
|
||||
nullable: existing?.nullable ?? true,
|
||||
type:
|
||||
existing && field.field in schemaOverview[field.collection].columns
|
||||
? getLocalType(schemaOverview[field.collection].columns[field.field], {
|
||||
special: field.special ? toArray(field.special) : [],
|
||||
})
|
||||
: 'alias',
|
||||
type: type,
|
||||
dbType: existing?.dbType || null,
|
||||
precision: existing?.precision || null,
|
||||
scale: existing?.scale || null,
|
||||
special: field.special ? toArray(field.special) : [],
|
||||
special: special,
|
||||
note: field.note,
|
||||
alias: existing?.alias ?? true,
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import { flatten, get, merge, set } from 'lodash';
|
||||
import logger from '../logger';
|
||||
import { Filter, Meta, Query, Sort } from '../types';
|
||||
import { Accountability } from '@directus/shared/types';
|
||||
import { parseFilter } from '@directus/shared/utils';
|
||||
import { parseFilter, deepMap } from '@directus/shared/utils';
|
||||
|
||||
export function sanitizeQuery(rawQuery: Record<string, any>, accountability?: Accountability | null): Query {
|
||||
const query: Query = {};
|
||||
@@ -96,6 +96,14 @@ function sanitizeFilter(rawFilter: any, accountability: Accountability | null) {
|
||||
}
|
||||
}
|
||||
|
||||
filters = deepMap(filters, (val) => {
|
||||
try {
|
||||
return JSON.parse(val);
|
||||
} catch {
|
||||
return val;
|
||||
}
|
||||
});
|
||||
|
||||
filters = parseFilter(filters, accountability);
|
||||
|
||||
return filters;
|
||||
|
||||
@@ -2,6 +2,7 @@ import Joi from 'joi';
|
||||
import { isPlainObject } from 'lodash';
|
||||
import { InvalidQueryException } from '../exceptions';
|
||||
import { Query } from '../types';
|
||||
import { stringify } from 'wellknown';
|
||||
|
||||
const querySchema = Joi.object({
|
||||
fields: Joi.array().items(Joi.string()),
|
||||
@@ -41,8 +42,6 @@ function validateFilter(filter: Query['filter']) {
|
||||
for (const [key, nested] of Object.entries(filter)) {
|
||||
if (key === '_and' || key === '_or') {
|
||||
nested.forEach(validateFilter);
|
||||
} else if (isPlainObject(nested)) {
|
||||
validateFilter(nested);
|
||||
} else if (key.startsWith('_')) {
|
||||
const value = nested;
|
||||
|
||||
@@ -74,8 +73,17 @@ function validateFilter(filter: Query['filter']) {
|
||||
case '_nempty':
|
||||
validateBoolean(value, key);
|
||||
break;
|
||||
|
||||
case '_intersects':
|
||||
case '_nintersects':
|
||||
case '_intersects_bbox':
|
||||
case '_nintersects_bbox':
|
||||
validateGeometry(value, key);
|
||||
break;
|
||||
}
|
||||
} else if (isPlainObject(nested) === false && Array.isArray(nested) === false) {
|
||||
} else if (isPlainObject(nested)) {
|
||||
validateFilter(nested);
|
||||
} else if (Array.isArray(nested) === false) {
|
||||
validateFilterPrimitive(nested, '_eq');
|
||||
} else {
|
||||
validateFilter(nested);
|
||||
@@ -121,3 +129,13 @@ function validateBoolean(value: any, key: string) {
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateGeometry(value: any, key: string) {
|
||||
try {
|
||||
stringify(value);
|
||||
} catch {
|
||||
throw new InvalidQueryException(`"${key}" has to be a valid GeoJSON object`);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -36,22 +36,30 @@
|
||||
"@fullcalendar/interaction": "5.9.0",
|
||||
"@fullcalendar/list": "5.9.0",
|
||||
"@fullcalendar/timegrid": "5.9.0",
|
||||
"@mapbox/mapbox-gl-draw": "^1.2.2",
|
||||
"@mapbox/mapbox-gl-draw-static-mode": "^1.0.1",
|
||||
"@mapbox/mapbox-gl-geocoder": "^4.7.1",
|
||||
"@popperjs/core": "2.9.3",
|
||||
"@rollup/plugin-yaml": "3.1.0",
|
||||
"@sindresorhus/slugify": "2.1.0",
|
||||
"@tinymce/tinymce-vue": "4.0.4",
|
||||
"@turf/meta": "^6.3.0",
|
||||
"@types/base-64": "1.0.0",
|
||||
"@types/bytes": "3.1.1",
|
||||
"@types/codemirror": "5.60.2",
|
||||
"@types/color": "3.0.2",
|
||||
"@types/diff": "5.0.1",
|
||||
"@types/dompurify": "2.2.3",
|
||||
"@types/geojson": "^7946.0.7",
|
||||
"@types/lodash": "4.14.172",
|
||||
"@types/mapbox__mapbox-gl-draw": "^1.2.1",
|
||||
"@types/mapbox__mapbox-gl-geocoder": "^4.7.0",
|
||||
"@types/markdown-it": "12.0.3",
|
||||
"@types/marked": "2.0.4",
|
||||
"@types/mime-types": "2.1.0",
|
||||
"@types/ms": "0.7.31",
|
||||
"@types/qrcode": "1.4.1",
|
||||
"@types/wellknown": "^0.5.1",
|
||||
"@vitejs/plugin-vue": "1.4.0",
|
||||
"@vue/cli-plugin-babel": "4.5.13",
|
||||
"@vue/cli-plugin-router": "4.5.13",
|
||||
@@ -70,6 +78,7 @@
|
||||
"front-matter": "4.0.2",
|
||||
"html-entities": "2.3.2",
|
||||
"jsonlint-mod": "1.7.6",
|
||||
"maplibre-gl": "^1.14.0",
|
||||
"marked": "2.1.3",
|
||||
"micromustache": "8.0.3",
|
||||
"mime": "2.5.2",
|
||||
@@ -88,6 +97,7 @@
|
||||
"vue": "3.2.2",
|
||||
"vue-i18n": "9.1.7",
|
||||
"vue-router": "4.0.11",
|
||||
"vuedraggable": "4.0.3"
|
||||
"vuedraggable": "4.0.3",
|
||||
"wellknown": "^0.5.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
:type="field.type"
|
||||
:collection="field.collection"
|
||||
:field="field.field"
|
||||
:field-data="field"
|
||||
:primary-key="primaryKey"
|
||||
:length="field.schema && field.schema.max_length"
|
||||
@input="$emit('update:modelValue', $event)"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Language } from '@/lang';
|
||||
import { setLanguage } from '@/lang/set-language';
|
||||
import { register as registerModules, unregister as unregisterModules } from '@/modules/register';
|
||||
import { getBasemapSources } from '@/utils/geometry/basemap';
|
||||
import {
|
||||
useAppStore,
|
||||
useCollectionsStore,
|
||||
@@ -63,6 +64,8 @@ export async function hydrate(stores = useStores()): Promise<void> {
|
||||
await registerModules();
|
||||
await setLanguage((userStore.currentUser?.language as Language) || 'en-US');
|
||||
}
|
||||
|
||||
appStore.basemap = getBasemapSources()[0].name;
|
||||
} catch (error) {
|
||||
appStore.error = error;
|
||||
} finally {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -22,10 +22,12 @@
|
||||
|
||||
edit_field: Edit Field
|
||||
conditions: Conditions
|
||||
maps: Maps
|
||||
item_revision: Item Revision
|
||||
duplicate_field: Duplicate Field
|
||||
half_width: Half Width
|
||||
full_width: Full Width
|
||||
limit: Limit
|
||||
group: Group
|
||||
and: And
|
||||
or: Or
|
||||
@@ -187,6 +189,7 @@ time: Time
|
||||
timestamp: Timestamp
|
||||
uuid: UUID
|
||||
hash: Hash
|
||||
geometry: Geometry
|
||||
not_available_for_type: Not Available for this Type
|
||||
create_translations: Create Translations
|
||||
auto_refresh: Auto Refresh
|
||||
@@ -386,6 +389,7 @@ no_users_copy: There are no users in this role yet.
|
||||
webhooks_count: 'No Webhooks | One Webhook | {count} Webhooks'
|
||||
no_webhooks_copy: There are no webhooks yet.
|
||||
all_items: All Items
|
||||
any: Any
|
||||
csv: CSV
|
||||
no_collections: No Collections
|
||||
create_collection: Create Collection
|
||||
@@ -504,6 +508,7 @@ color: Color
|
||||
circle: Circle
|
||||
empty_item: Empty Item
|
||||
log_in_with: 'Log In with {provider}'
|
||||
advanced_settings: Advanced Settings
|
||||
advanced_filter: Advanced Filter
|
||||
delete_advanced_filter: Delete Filter
|
||||
change_advanced_filter_operator: Change Operator
|
||||
@@ -530,6 +535,10 @@ operators:
|
||||
nempty: Isn't empty
|
||||
all: Contains these keys
|
||||
has: Contains some of these keys
|
||||
intersects: Intersects
|
||||
nintersects: Doesn't intersect
|
||||
intersects_bbox: Intersects bounding box
|
||||
nintersects_bbox: Doesn't intersect bounding box
|
||||
loading: Loading...
|
||||
drop_to_upload: Drop to Upload
|
||||
item: Item
|
||||
@@ -944,6 +953,7 @@ interfaces:
|
||||
group-accordion:
|
||||
name: Accordion
|
||||
description: Display fields or groups as accordion sections
|
||||
start: Start
|
||||
all_closed: All Closed
|
||||
first_opened: First Opened
|
||||
all_opened: All Opened
|
||||
@@ -1064,6 +1074,22 @@ interfaces:
|
||||
box: Block / Inline
|
||||
imageToken: Image Token
|
||||
imageToken_label: What (static) token to append to image sources
|
||||
map:
|
||||
map: Map
|
||||
description: Select a location on a map
|
||||
zoom: Zoom
|
||||
geometry_type: Geometry type
|
||||
geometry_format: Geometry format
|
||||
default_view: Default view
|
||||
invalid_options: Invalid options
|
||||
invalid_format: Invalid format ({format})
|
||||
unexpected_geometry: Expected {expected}, got {got}.
|
||||
fit_bounds: Fit view to data
|
||||
native: Native
|
||||
geojson: GeoJSON
|
||||
lnglat: Longitude, Latitude
|
||||
wkt: WKT
|
||||
wkb: WKB
|
||||
presentation-notice:
|
||||
notice: Notice
|
||||
description: Display a short notice
|
||||
@@ -1273,3 +1299,16 @@ layouts:
|
||||
calendar: Calendar
|
||||
start_date_field: Start Date Field
|
||||
end_date_field: End Date Field
|
||||
map:
|
||||
map: Map
|
||||
basemap: Basemap
|
||||
layers: Layers
|
||||
edit_custom_layers: Edit Layers
|
||||
cluster_options: Clustering options
|
||||
cluster: Activate clustering
|
||||
cluster_radius: Cluster radius
|
||||
cluster_minpoints: Cluster minimum size
|
||||
cluster_maxzoom: Maximum zoom for clustering
|
||||
fit_data: Fit data to view bounds
|
||||
field: Geometry
|
||||
invalid_geometry: Invalid geometry
|
||||
|
||||
@@ -168,7 +168,7 @@ bigInteger: Grand entier
|
||||
boolean: Booléen
|
||||
date: Date
|
||||
datetime: Date et heure
|
||||
decimal: Décimale
|
||||
decimal: Décimal
|
||||
float: Décimal
|
||||
integer: Entier
|
||||
json: JSON
|
||||
@@ -179,6 +179,7 @@ time: Date et heure
|
||||
timestamp: Horodatage
|
||||
uuid: UUID (IDentifiant Unique Universel)
|
||||
hash: Hash
|
||||
geometry: Geometrie
|
||||
not_available_for_type: Non disponible pour ce type
|
||||
create_translations: Créer des traductions
|
||||
auto_refresh: Actualisation automatique
|
||||
@@ -1002,6 +1003,18 @@ interfaces:
|
||||
box: Bloc / Inline
|
||||
imageToken: Token d'image
|
||||
imageToken_label: Quel token (statique) ajouter aux sources d'images
|
||||
map:
|
||||
map: Map
|
||||
description: Selectionner un lieu sur une carte
|
||||
geometry_type: Type de géométrie
|
||||
geometry_format: Format de géométrie
|
||||
storage_type: Storage type
|
||||
default_view: Vue par défault
|
||||
invalid_options: Options invalides
|
||||
invalid_format: Format invalide ( {format} )
|
||||
unexpected_geometry: Attendu {expected}, reçu {got}.
|
||||
fit_bounds: Ajuster la vue aux donnnées
|
||||
native: Natif
|
||||
presentation-notice:
|
||||
notice: Remarque
|
||||
description: Afficher une courte remarque
|
||||
@@ -1198,3 +1211,15 @@ layouts:
|
||||
calendar: Calendrier
|
||||
start_date_field: Champ Date de début
|
||||
end_date_field: Champ Date de fin
|
||||
map:
|
||||
map: Carte
|
||||
layers: Couche,
|
||||
edit_custom_layers: Éditer les couches
|
||||
cluster_options: Options de partitionnement
|
||||
cluster: Activer le partitionnement
|
||||
cluster_radius: Rayon de partitionnement
|
||||
cluster_minpoints: Taille minimum de partitionnement
|
||||
cluster_maxzoom: Zoom maximum du partitionnement
|
||||
fit_data: Filter les données selon la position
|
||||
field: Geometrie
|
||||
invalid_geometry: Geometrie invalide
|
||||
|
||||
46
app/src/layouts/map/actions.vue
Normal file
46
app/src/layouts/map/actions.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<transition name="fade">
|
||||
<span v-if="itemCount" class="item-count">
|
||||
{{ showingCount }}
|
||||
</span>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, toRefs } from 'vue';
|
||||
|
||||
import { useLayoutState } from '@directus/shared/composables';
|
||||
|
||||
export default defineComponent({
|
||||
setup() {
|
||||
const layoutState = useLayoutState();
|
||||
const { itemCount, showingCount } = toRefs(layoutState.value);
|
||||
|
||||
return { itemCount, showingCount };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.item-count {
|
||||
position: relative;
|
||||
display: none;
|
||||
margin: 0 8px;
|
||||
color: var(--foreground-subdued);
|
||||
white-space: nowrap;
|
||||
|
||||
@media (min-width: 600px) {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity var(--medium) var(--transition);
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
511
app/src/layouts/map/components/map.vue
Normal file
511
app/src/layouts/map/components/map.vue
Normal file
@@ -0,0 +1,511 @@
|
||||
<template>
|
||||
<div
|
||||
id="map-container"
|
||||
ref="container"
|
||||
:class="{ select: selectMode, hover: hoveredFeature || hoveredCluster }"
|
||||
></div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import {
|
||||
MapboxGeoJSONFeature,
|
||||
MapLayerMouseEvent,
|
||||
AttributionControl,
|
||||
NavigationControl,
|
||||
GeolocateControl,
|
||||
LngLatBoundsLike,
|
||||
GeoJSONSource,
|
||||
CameraOptions,
|
||||
LngLatLike,
|
||||
AnyLayer,
|
||||
Popup,
|
||||
Map,
|
||||
} from 'maplibre-gl';
|
||||
import MapboxGeocoder from '@mapbox/mapbox-gl-geocoder';
|
||||
import '@mapbox/mapbox-gl-geocoder/dist/mapbox-gl-geocoder.css';
|
||||
import { ref, watch, PropType, onMounted, onUnmounted, defineComponent, toRefs, computed, WatchStopHandle } from 'vue';
|
||||
|
||||
import getSetting from '@/utils/get-setting';
|
||||
import { useAppStore } from '@/stores';
|
||||
import { BoxSelectControl, ButtonControl } from '@/utils/geometry/controls';
|
||||
import { getBasemapSources, getStyleFromBasemapSource } from '@/utils/geometry/basemap';
|
||||
|
||||
export default defineComponent({
|
||||
components: {},
|
||||
props: {
|
||||
data: {
|
||||
type: Object as PropType<GeoJSON.FeatureCollection>,
|
||||
required: true,
|
||||
},
|
||||
source: {
|
||||
type: Object as PropType<GeoJSONSource>,
|
||||
required: true,
|
||||
},
|
||||
layers: {
|
||||
type: Array as PropType<AnyLayer[]>,
|
||||
default: () => [],
|
||||
},
|
||||
camera: {
|
||||
type: Object as PropType<CameraOptions & { bbox: any }>,
|
||||
default: () => ({} as any),
|
||||
},
|
||||
bounds: {
|
||||
type: Array as unknown as PropType<GeoJSON.BBox>,
|
||||
default: undefined,
|
||||
},
|
||||
featureId: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
selection: {
|
||||
type: Array as PropType<Array<string | number>>,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
emits: ['moveend', 'featureclick', 'featureselect'],
|
||||
setup(props, { emit }) {
|
||||
const appStore = useAppStore();
|
||||
let map: Map;
|
||||
const hoveredFeature = ref<MapboxGeoJSONFeature>();
|
||||
const hoveredCluster = ref<boolean>();
|
||||
const selectMode = ref<boolean>();
|
||||
const container = ref<HTMLElement>();
|
||||
const unwatchers = [] as WatchStopHandle[];
|
||||
const { sidebarOpen, basemap } = toRefs(appStore);
|
||||
const basemaps = getBasemapSources();
|
||||
const style = computed(() => {
|
||||
const source = basemaps.find((source) => source.name === basemap.value) ?? basemaps[0];
|
||||
return getStyleFromBasemapSource(source);
|
||||
});
|
||||
|
||||
const popup = new Popup({
|
||||
closeButton: false,
|
||||
closeOnClick: false,
|
||||
className: 'mapboxgl-point-popup',
|
||||
maxWidth: 'unset',
|
||||
offset: 20,
|
||||
});
|
||||
|
||||
const attributionControl = new AttributionControl({ compact: true });
|
||||
const navigationControl = new NavigationControl();
|
||||
const geolocateControl = new GeolocateControl();
|
||||
const fitDataControl = new ButtonControl('mapboxgl-ctrl-fitdata', () => {
|
||||
emit('moveend', null);
|
||||
});
|
||||
const boxSelectControl = new BoxSelectControl({
|
||||
boxElementClass: 'selection-box',
|
||||
selectButtonClass: 'mapboxgl-ctrl-select',
|
||||
unselectButtonClass: 'mapboxgl-ctrl-unselect',
|
||||
layers: ['__directus_polygons', '__directus_points', '__directus_lines'],
|
||||
});
|
||||
const mapboxKey = getSetting('mapbox_key');
|
||||
|
||||
onMounted(() => {
|
||||
setupMap();
|
||||
});
|
||||
onUnmounted(() => {
|
||||
map.remove();
|
||||
});
|
||||
|
||||
return { container, hoveredFeature, hoveredCluster, selectMode };
|
||||
|
||||
function setupMap() {
|
||||
map = new Map({
|
||||
container: 'map-container',
|
||||
style: style.value,
|
||||
attributionControl: false,
|
||||
...props.camera,
|
||||
...(mapboxKey ? { accessToken: mapboxKey } : {}),
|
||||
});
|
||||
|
||||
if (mapboxKey) {
|
||||
map.addControl(new MapboxGeocoder({ accessToken: mapboxKey, marker: false }) as any, 'top-right');
|
||||
}
|
||||
map.addControl(attributionControl, 'top-right');
|
||||
map.addControl(navigationControl, 'top-left');
|
||||
map.addControl(geolocateControl, 'top-left');
|
||||
map.addControl(fitDataControl, 'top-left');
|
||||
map.addControl(boxSelectControl, 'top-left');
|
||||
|
||||
map.on('load', () => {
|
||||
watch(() => style.value, updateStyle);
|
||||
watch(() => props.bounds, fitBounds);
|
||||
map.on('click', '__directus_polygons', onFeatureClick);
|
||||
map.on('mousemove', '__directus_polygons', updatePopup);
|
||||
map.on('mouseleave', '__directus_polygons', updatePopup);
|
||||
map.on('click', '__directus_points', onFeatureClick);
|
||||
map.on('mousemove', '__directus_points', updatePopup);
|
||||
map.on('mouseleave', '__directus_points', updatePopup);
|
||||
map.on('click', '__directus_lines', onFeatureClick);
|
||||
map.on('mousemove', '__directus_lines', updatePopup);
|
||||
map.on('mouseleave', '__directus_lines', updatePopup);
|
||||
map.on('click', '__directus_clusters', expandCluster);
|
||||
map.on('mousemove', '__directus_clusters', hoverCluster);
|
||||
map.on('mouseleave', '__directus_clusters', hoverCluster);
|
||||
map.on('select.enable', () => (selectMode.value = true));
|
||||
map.on('select.disable', () => (selectMode.value = false));
|
||||
map.on('select.end', (event: MapLayerMouseEvent) => {
|
||||
const ids = event.features?.map((f) => f.id);
|
||||
emit('featureselect', ids);
|
||||
});
|
||||
map.on('moveend', (event) => {
|
||||
if (!event.originalEvent) {
|
||||
return;
|
||||
}
|
||||
emit('moveend', {
|
||||
center: map.getCenter(),
|
||||
zoom: map.getZoom(),
|
||||
bearing: map.getBearing(),
|
||||
pitch: map.getPitch(),
|
||||
bbox: map.getBounds().toArray().flat(),
|
||||
});
|
||||
});
|
||||
startWatchers();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => sidebarOpen.value,
|
||||
(opened) => {
|
||||
if (!opened) setTimeout(() => map.resize(), 300);
|
||||
}
|
||||
);
|
||||
setTimeout(() => map.resize(), 300);
|
||||
}
|
||||
|
||||
function fitBounds() {
|
||||
const bbox = props.data.bbox as LngLatBoundsLike;
|
||||
if (map && bbox) {
|
||||
map.fitBounds(bbox, {
|
||||
padding: 100,
|
||||
speed: 1.3,
|
||||
maxZoom: 14,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function updateStyle(style: any) {
|
||||
unwatchers.forEach((unwatch) => unwatch());
|
||||
unwatchers.length = 0;
|
||||
map.setStyle(style, { diff: false });
|
||||
map.once('styledata', startWatchers);
|
||||
}
|
||||
|
||||
function startWatchers() {
|
||||
unwatchers.push(
|
||||
watch(() => props.source, updateSource, { immediate: true }),
|
||||
watch(() => props.selection, updateSelection, { immediate: true }),
|
||||
watch(() => props.layers, updateLayers),
|
||||
watch(() => props.data, updateData)
|
||||
);
|
||||
}
|
||||
|
||||
function updateData(newData: any) {
|
||||
const source = map.getSource('__directus');
|
||||
(source as GeoJSONSource).setData(newData);
|
||||
updateSelection(props.selection, undefined);
|
||||
}
|
||||
|
||||
function updateSource(newSource: GeoJSONSource) {
|
||||
const layersId = new Set(map.getStyle().layers?.map(({ id }) => id));
|
||||
for (const layer of props.layers) {
|
||||
if (layersId.has(layer.id)) {
|
||||
map.removeLayer(layer.id);
|
||||
}
|
||||
}
|
||||
if (props.featureId) {
|
||||
(newSource as any).promoteId = props.featureId;
|
||||
} else {
|
||||
(newSource as any).generateId = true;
|
||||
}
|
||||
if (map.getStyle().sources?.['__directus']) {
|
||||
map.removeSource('__directus');
|
||||
}
|
||||
map.addSource('__directus', { ...newSource, data: props.data });
|
||||
map.once('sourcedata', () => {
|
||||
setTimeout(() => props.layers.forEach((layer) => map.addLayer(layer)));
|
||||
});
|
||||
}
|
||||
|
||||
function updateLayers(newLayers?: AnyLayer[], previousLayers?: AnyLayer[]) {
|
||||
const currentMapLayersId = new Set(map.getStyle().layers?.map(({ id }) => id));
|
||||
previousLayers?.forEach((layer) => {
|
||||
if (currentMapLayersId.has(layer.id)) map.removeLayer(layer.id);
|
||||
});
|
||||
newLayers?.forEach((layer) => {
|
||||
map.addLayer(layer);
|
||||
});
|
||||
}
|
||||
|
||||
function updateSelection(newSelection?: (string | number)[], previousSelection?: (string | number)[]) {
|
||||
previousSelection?.forEach((id) => {
|
||||
map.setFeatureState({ id, source: '__directus' }, { selected: false });
|
||||
map.removeFeatureState({ id, source: '__directus' });
|
||||
});
|
||||
newSelection?.forEach((id) => {
|
||||
map.setFeatureState({ id, source: '__directus' }, { selected: true });
|
||||
});
|
||||
}
|
||||
|
||||
function onFeatureClick(event: MapLayerMouseEvent) {
|
||||
const feature = event.features?.[0];
|
||||
if (feature && props.featureId) {
|
||||
if (boxSelectControl.active()) {
|
||||
emit('featureselect', [feature.id]);
|
||||
} else {
|
||||
emit('featureclick', feature.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updatePopup(event: MapLayerMouseEvent) {
|
||||
const feature = map.queryRenderedFeatures(event.point, {
|
||||
layers: ['__directus_polygons', '__directus_points', '__directus_lines'],
|
||||
})[0];
|
||||
|
||||
const previousId = hoveredFeature.value?.id;
|
||||
const featureChanged = previousId !== feature?.id;
|
||||
if (previousId && featureChanged) {
|
||||
map.setFeatureState({ id: previousId, source: '__directus' }, { hovered: false });
|
||||
}
|
||||
if (feature && feature.properties) {
|
||||
if (feature.geometry.type === 'Point') {
|
||||
popup.setLngLat(feature.geometry.coordinates as LngLatLike);
|
||||
} else {
|
||||
popup.setLngLat(event.lngLat);
|
||||
}
|
||||
if (featureChanged) {
|
||||
map.setFeatureState({ id: feature.id, source: '__directus' }, { hovered: true });
|
||||
popup.setHTML(feature.properties.description).addTo(map);
|
||||
hoveredFeature.value = feature;
|
||||
}
|
||||
} else {
|
||||
if (featureChanged) {
|
||||
hoveredFeature.value = feature;
|
||||
popup.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function expandCluster(event: MapLayerMouseEvent) {
|
||||
const features = map.queryRenderedFeatures(event.point, {
|
||||
layers: ['__directus_clusters'],
|
||||
});
|
||||
const clusterId = features[0]?.properties?.cluster_id;
|
||||
const source = map.getSource('__directus') as GeoJSONSource;
|
||||
source.getClusterExpansionZoom(clusterId, (err: any, zoom: number) => {
|
||||
if (err) return;
|
||||
map.flyTo({
|
||||
center: (features[0].geometry as GeoJSON.Point).coordinates as LngLatLike,
|
||||
zoom: zoom,
|
||||
speed: 1.3,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function hoverCluster(event: MapLayerMouseEvent) {
|
||||
if (event.type == 'mousemove') {
|
||||
hoveredCluster.value = true;
|
||||
} else {
|
||||
hoveredCluster.value = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.mapboxgl-map {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.mapboxgl-ctrl-group {
|
||||
overflow: hidden;
|
||||
background: none;
|
||||
|
||||
&:not(:empty) {
|
||||
box-shadow: 0 0 3px 1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
button {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: var(--background-subdued) !important;
|
||||
border: none !important;
|
||||
|
||||
span {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
& + button {
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--background-normal) !important;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--background-subdued) !important;
|
||||
background: var(--foreground-normal) !important;
|
||||
}
|
||||
|
||||
&.hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
button::after {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
font-family: 'Material Icons Outline', sans-serif;
|
||||
font-style: normal;
|
||||
font-variant: normal;
|
||||
text-rendering: auto;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
}
|
||||
|
||||
.mapboxgl-ctrl-zoom-in::after {
|
||||
content: 'add';
|
||||
}
|
||||
|
||||
.mapboxgl-ctrl-zoom-out::after {
|
||||
content: 'remove';
|
||||
}
|
||||
|
||||
.mapboxgl-ctrl-compass::after {
|
||||
content: 'explore';
|
||||
}
|
||||
|
||||
.mapboxgl-ctrl-geolocate::after {
|
||||
content: 'my_location';
|
||||
}
|
||||
|
||||
.mapboxgl-ctrl-fitdata::after {
|
||||
content: 'crop_free';
|
||||
}
|
||||
|
||||
.mapboxgl-ctrl-select::after {
|
||||
content: 'highlight_alt';
|
||||
}
|
||||
|
||||
.mapboxgl-ctrl-unselect::after {
|
||||
content: 'clear';
|
||||
}
|
||||
|
||||
.mapboxgl-ctrl-attrib.mapboxgl-compact {
|
||||
min-width: 24px;
|
||||
min-height: 24px;
|
||||
color: var(--foreground-normal);
|
||||
background: var(--background-subdued) !important;
|
||||
}
|
||||
|
||||
.mapboxgl-ctrl-geocoder {
|
||||
font-size: inherit !important;
|
||||
font-family: inherit !important;
|
||||
line-height: inherit !important;
|
||||
background-color: var(--background-subdued);
|
||||
|
||||
&,
|
||||
&.suggestions {
|
||||
box-shadow: 0 0 3px 1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.mapboxgl-ctrl-geocoder--input {
|
||||
color: var(--foreground-normal) !important;
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.mapboxgl-ctrl-geocoder .suggestions {
|
||||
background-color: var(--background-subdued);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.mapboxgl-ctrl-geocoder .suggestions > li > a {
|
||||
color: var(--foreground-normal);
|
||||
}
|
||||
|
||||
.mapboxgl-ctrl-geocoder .suggestions > .active > a,
|
||||
.mapboxgl-ctrl-geocoder .suggestions > li > a:hover {
|
||||
color: var(--v-list-item-color-active);
|
||||
background-color: var(--background-normal-alt);
|
||||
}
|
||||
|
||||
.mapboxgl-ctrl-geocoder--button {
|
||||
background: var(--background-subdued);
|
||||
}
|
||||
|
||||
.mapboxgl-ctrl-geocoder--icon {
|
||||
fill: var(--v-icon-color);
|
||||
}
|
||||
|
||||
.mapboxgl-ctrl-geocoder--button:hover .mapboxgl-ctrl-geocoder--icon-close {
|
||||
fill: var(--v-icon-color-hover);
|
||||
}
|
||||
|
||||
.mapbox-gl-geocoder--error {
|
||||
color: var(--foreground-subdued);
|
||||
}
|
||||
|
||||
.selection-box {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 1000;
|
||||
width: 0;
|
||||
height: 0;
|
||||
background: rgba(56, 135, 190, 0.1);
|
||||
border: 1px solid rgb(56, 135, 190);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mapboxgl-point-popup {
|
||||
&.mapboxgl-popup-anchor-left .mapboxgl-popup-tip {
|
||||
border-right-color: var(--background-normal);
|
||||
}
|
||||
|
||||
&.mapboxgl-popup-anchor-top .mapboxgl-popup-tip {
|
||||
border-bottom-color: var(--background-normal);
|
||||
}
|
||||
|
||||
&.mapboxgl-popup-anchor-bottom .mapboxgl-popup-tip {
|
||||
border-top-color: var(--background-normal);
|
||||
}
|
||||
|
||||
&.mapboxgl-popup-anchor-right .mapboxgl-popup-tip {
|
||||
border-left-color: var(--background-normal);
|
||||
}
|
||||
|
||||
.mapboxgl-popup-content {
|
||||
color: var(--foreground-normal-alt);
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
font-family: var(--family-sans-serif);
|
||||
background-color: var(--background-normal);
|
||||
border-radius: var(--border-radius);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
#map-container.hover .mapboxgl-canvas-container {
|
||||
cursor: pointer !important;
|
||||
}
|
||||
|
||||
#map-container.select .mapboxgl-canvas-container {
|
||||
cursor: crosshair !important;
|
||||
}
|
||||
</style>
|
||||
<style lang="scss" scoped>
|
||||
#map-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
363
app/src/layouts/map/index.ts
Normal file
363
app/src/layouts/map/index.ts
Normal file
@@ -0,0 +1,363 @@
|
||||
import { defineLayout } from '@directus/shared/utils';
|
||||
import MapLayout from './map.vue';
|
||||
import MapOptions from './options.vue';
|
||||
import MapSidebar from './sidebar.vue';
|
||||
import MapActions from './actions.vue';
|
||||
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { toRefs, computed, ref, watch, Ref } from 'vue';
|
||||
|
||||
import { CameraOptions, AnyLayer } from 'maplibre-gl';
|
||||
import { GeometryOptions, toGeoJSON } from '@/utils/geometry';
|
||||
import { layers } from './style';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { Filter } from '@directus/shared/types';
|
||||
import useCollection from '@/composables/use-collection/';
|
||||
import useItems from '@/composables/use-items';
|
||||
import { getFieldsFromTemplate } from '@/utils/get-fields-from-template';
|
||||
import type { Field, GeometryFormat } from '@directus/shared/types';
|
||||
|
||||
import { cloneDeep, merge } from 'lodash';
|
||||
|
||||
type LayoutQuery = {
|
||||
fields: string[];
|
||||
sort: string;
|
||||
limit: number;
|
||||
page: number;
|
||||
};
|
||||
|
||||
type LayoutOptions = {
|
||||
cameraOptions?: CameraOptions & { bbox: any };
|
||||
customLayers?: Array<AnyLayer>;
|
||||
geometryFormat?: GeometryFormat;
|
||||
geometryField?: string;
|
||||
fitDataToView?: boolean;
|
||||
clusterData?: boolean;
|
||||
animateOptions?: any;
|
||||
};
|
||||
|
||||
export default defineLayout<LayoutOptions, LayoutQuery>({
|
||||
id: 'map',
|
||||
name: '$t:layouts.map.map',
|
||||
icon: 'map',
|
||||
smallHeader: true,
|
||||
component: MapLayout,
|
||||
slots: {
|
||||
options: MapOptions,
|
||||
sidebar: MapSidebar,
|
||||
actions: MapActions,
|
||||
},
|
||||
setup(props) {
|
||||
const { t, n } = useI18n();
|
||||
const router = useRouter();
|
||||
|
||||
const { collection, searchQuery, selection, layoutOptions, layoutQuery, filters } = toRefs(props);
|
||||
const { info, primaryKeyField, fields: fieldsInCollection } = useCollection(collection);
|
||||
|
||||
const page = syncOption(layoutQuery, 'page', 1);
|
||||
const limit = syncOption(layoutQuery, 'limit', 1000);
|
||||
const sort = syncOption(layoutQuery, 'sort', fieldsInCollection.value[0].field);
|
||||
|
||||
const customLayerDrawerOpen = ref(false);
|
||||
const layoutElement = ref<HTMLElement | null>(null);
|
||||
|
||||
const cameraOptions = syncOption(layoutOptions, 'cameraOptions', undefined);
|
||||
const customLayers = syncOption(layoutOptions, 'customLayers', layers);
|
||||
const fitDataToView = syncOption(layoutOptions, 'fitDataToView', true);
|
||||
const clusterData = syncOption(layoutOptions, 'clusterData', false);
|
||||
const geometryField = syncOption(layoutOptions, 'geometryField', undefined);
|
||||
const geometryFormat = computed<GeometryFormat | undefined>({
|
||||
get: () => layoutOptions.value?.geometryFormat,
|
||||
set(newValue: GeometryFormat | undefined) {
|
||||
layoutOptions.value = {
|
||||
...(layoutOptions.value || {}),
|
||||
geometryFormat: newValue,
|
||||
geometryField: undefined,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const geometryFields = computed(() => {
|
||||
return (fieldsInCollection.value as Field[]).filter(
|
||||
({ type, meta }) => type == 'geometry' || meta?.interface == 'map'
|
||||
);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => geometryFields.value,
|
||||
(fields) => {
|
||||
if (!geometryField.value && fields.length > 0) {
|
||||
geometryField.value = fields[0].field;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const geometryOptions = computed<GeometryOptions | undefined>(() => {
|
||||
const field = fieldsInCollection.value.filter((field: Field) => field.field == geometryField.value)[0];
|
||||
if (!field) return undefined;
|
||||
if (field.type == 'geometry') {
|
||||
return {
|
||||
geometryField: field.field,
|
||||
geometryFormat: 'native',
|
||||
geometryType: field.schema?.geometry_type,
|
||||
} as GeometryOptions;
|
||||
}
|
||||
if (field.meta && field.meta.interface == 'map' && field.meta.options) {
|
||||
return {
|
||||
geometryField: field.field,
|
||||
geometryFormat: field.meta.options.geometryFormat,
|
||||
geometryType: field.meta.options.geometryType,
|
||||
} as GeometryOptions;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => geometryOptions.value,
|
||||
(options, _) => {
|
||||
if (options?.geometryFormat !== 'native') {
|
||||
fitDataToView.value = false;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const template = computed(() => {
|
||||
if (info.value?.meta?.display_template) return info.value?.meta?.display_template;
|
||||
return `{{ ${primaryKeyField.value?.field} }}`;
|
||||
});
|
||||
|
||||
const queryFields = computed(() => {
|
||||
return [geometryField.value, ...getFieldsFromTemplate(template.value)]
|
||||
.concat(primaryKeyField.value?.field)
|
||||
.filter((e) => !!e) as string[];
|
||||
});
|
||||
|
||||
const viewBoundsFilter = computed<Filter | undefined>(() => {
|
||||
if (!geometryField.value || !cameraOptions.value) {
|
||||
return;
|
||||
}
|
||||
const bbox = cameraOptions.value?.bbox;
|
||||
const bboxPolygon = [
|
||||
[bbox[0], bbox[1]],
|
||||
[bbox[2], bbox[1]],
|
||||
[bbox[2], bbox[3]],
|
||||
[bbox[0], bbox[3]],
|
||||
[bbox[0], bbox[1]],
|
||||
];
|
||||
return {
|
||||
key: 'bbox-filter',
|
||||
field: geometryField.value,
|
||||
operator: 'intersects_bbox',
|
||||
value: {
|
||||
type: 'Polygon',
|
||||
coordinates: [bboxPolygon],
|
||||
} as any,
|
||||
} as Filter;
|
||||
});
|
||||
|
||||
const shouldUpdateCamera = ref(false);
|
||||
const _filters = computed(() => {
|
||||
if (geometryOptions.value?.geometryFormat === 'native' && fitDataToView.value) {
|
||||
return filters.value.concat(viewBoundsFilter.value ?? []);
|
||||
}
|
||||
return filters.value;
|
||||
});
|
||||
|
||||
const { items, loading, error, totalPages, itemCount, totalCount, getItems } = useItems(collection, {
|
||||
sort,
|
||||
limit,
|
||||
page,
|
||||
searchQuery,
|
||||
fields: queryFields,
|
||||
filters: _filters,
|
||||
});
|
||||
|
||||
const geojson = ref<GeoJSON.FeatureCollection>({ type: 'FeatureCollection', features: [] });
|
||||
const geojsonBounds = ref<GeoJSON.BBox>();
|
||||
const geojsonError = ref<string | null>();
|
||||
const geojsonLoading = ref(false);
|
||||
|
||||
watch(
|
||||
() => cameraOptions.value,
|
||||
() => {
|
||||
shouldUpdateCamera.value = false;
|
||||
}
|
||||
);
|
||||
|
||||
watch(() => searchQuery.value, onQueryChange);
|
||||
watch(() => collection.value, onQueryChange);
|
||||
watch(() => limit.value, onQueryChange);
|
||||
watch(() => sort.value, onQueryChange);
|
||||
watch(() => items.value, updateGeojson);
|
||||
|
||||
watch(
|
||||
() => geometryField.value,
|
||||
() => (shouldUpdateCamera.value = true)
|
||||
);
|
||||
|
||||
function onQueryChange() {
|
||||
shouldUpdateCamera.value = true;
|
||||
geojsonLoading.value = false;
|
||||
page.value = 1;
|
||||
}
|
||||
|
||||
function updateGeojson() {
|
||||
if (geometryOptions.value) {
|
||||
try {
|
||||
geojson.value = { type: 'FeatureCollection', features: [] };
|
||||
geojsonLoading.value = true;
|
||||
geojsonError.value = null;
|
||||
geojson.value = toGeoJSON(items.value, geometryOptions.value, template.value);
|
||||
geojsonLoading.value = false;
|
||||
if (!cameraOptions.value || shouldUpdateCamera.value) {
|
||||
geojsonBounds.value = geojson.value.bbox;
|
||||
}
|
||||
} catch (error) {
|
||||
geojsonLoading.value = false;
|
||||
geojsonError.value = error;
|
||||
geojson.value = { type: 'FeatureCollection', features: [] };
|
||||
}
|
||||
} else {
|
||||
geojson.value = { type: 'FeatureCollection', features: [] };
|
||||
}
|
||||
}
|
||||
|
||||
const directusLayers = ref(layers);
|
||||
const directusSource = ref({
|
||||
type: 'geojson',
|
||||
data: {
|
||||
type: 'FeatureCollection',
|
||||
features: [],
|
||||
},
|
||||
});
|
||||
|
||||
watch(() => clusterData.value, updateSource, { immediate: true });
|
||||
updateLayers();
|
||||
|
||||
function updateLayers() {
|
||||
customLayerDrawerOpen.value = false;
|
||||
directusLayers.value = customLayers.value ?? [];
|
||||
}
|
||||
|
||||
function resetLayers() {
|
||||
directusLayers.value = cloneDeep(layers);
|
||||
customLayers.value = directusLayers.value;
|
||||
}
|
||||
|
||||
function updateSource() {
|
||||
directusSource.value = merge({}, directusSource.value, {
|
||||
cluster: clusterData.value,
|
||||
});
|
||||
}
|
||||
|
||||
function updateSelection(selected: Array<string | number> | null) {
|
||||
if (selected) {
|
||||
selection.value = Array.from(new Set(selection.value.concat(selected)));
|
||||
} else {
|
||||
selection.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
const featureId = computed(() => {
|
||||
return props.readonly ? null : primaryKeyField.value?.field;
|
||||
});
|
||||
|
||||
function handleClick(key: number | string) {
|
||||
if (props.selectMode) {
|
||||
updateSelection([key]);
|
||||
} else {
|
||||
router.push(`/collections/${collection.value}/${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
const showingCount = computed(() => {
|
||||
if ((itemCount.value || 0) < (totalCount.value || 0)) {
|
||||
if (itemCount.value === 1) {
|
||||
return t('one_filtered_item');
|
||||
}
|
||||
return t('start_end_of_count_filtered_items', {
|
||||
start: n((+page.value - 1) * limit.value + 1),
|
||||
end: n(Math.min(page.value * limit.value, itemCount.value || 0)),
|
||||
count: n(itemCount.value || 0),
|
||||
});
|
||||
}
|
||||
if (itemCount.value === 1) {
|
||||
return t('one_item');
|
||||
}
|
||||
return t('start_end_of_count_items', {
|
||||
start: n((+page.value - 1) * limit.value + 1),
|
||||
end: n(Math.min(page.value * limit.value, itemCount.value || 0)),
|
||||
count: n(itemCount.value || 0),
|
||||
});
|
||||
});
|
||||
|
||||
const activeFilterCount = computed(() => {
|
||||
return filters.value.filter((filter) => !filter.locked).length;
|
||||
});
|
||||
|
||||
return {
|
||||
template,
|
||||
selection,
|
||||
geojson,
|
||||
directusSource,
|
||||
directusLayers,
|
||||
customLayers,
|
||||
updateLayers,
|
||||
resetLayers,
|
||||
featureId,
|
||||
geojsonBounds,
|
||||
geojsonLoading,
|
||||
geojsonError,
|
||||
geometryOptions,
|
||||
handleClick,
|
||||
geometryFormat,
|
||||
geometryField,
|
||||
cameraOptions,
|
||||
fitDataToView,
|
||||
clusterData,
|
||||
updateSelection,
|
||||
items,
|
||||
loading,
|
||||
error,
|
||||
totalPages,
|
||||
page,
|
||||
toPage,
|
||||
itemCount,
|
||||
fieldsInCollection,
|
||||
limit,
|
||||
primaryKeyField,
|
||||
sort,
|
||||
info,
|
||||
showingCount,
|
||||
layoutElement,
|
||||
activeFilterCount,
|
||||
refresh,
|
||||
resetPresetAndRefresh,
|
||||
geometryFields,
|
||||
customLayerDrawerOpen,
|
||||
};
|
||||
|
||||
async function resetPresetAndRefresh() {
|
||||
await props?.resetPreset?.();
|
||||
refresh();
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
getItems();
|
||||
}
|
||||
|
||||
function toPage(newPage: number) {
|
||||
page.value = newPage;
|
||||
}
|
||||
|
||||
function syncOption<R, T extends keyof R>(ref: Ref<R>, key: T, defaultValue: R[T]) {
|
||||
return computed<R[T]>({
|
||||
get: () => ref.value?.[key] ?? defaultValue,
|
||||
set: (value: R[T]) => {
|
||||
ref.value = Object.assign({}, ref.value, { [key]: value }) as R;
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
255
app/src/layouts/map/map.vue
Normal file
255
app/src/layouts/map/map.vue
Normal file
@@ -0,0 +1,255 @@
|
||||
<template>
|
||||
<div ref="layoutElement" class="layout-map">
|
||||
<map-component
|
||||
ref="map"
|
||||
class="mapboxgl-map"
|
||||
:class="{ loading, error: error || geojsonError || !geometryOptions }"
|
||||
:data="geojson"
|
||||
:feature-id="featureId"
|
||||
:selection="selection"
|
||||
:camera="cameraOptions"
|
||||
:bounds="geojsonBounds"
|
||||
:source="directusSource"
|
||||
:layers="directusLayers"
|
||||
@featureclick="handleClick"
|
||||
@featureselect="updateSelection"
|
||||
@moveend="cameraOptions = $event"
|
||||
/>
|
||||
|
||||
<transition name="fade">
|
||||
<v-info v-if="error" type="danger" :title="t('unexpected_error')" icon="error" center>
|
||||
{{ t('unexpected_error_copy') }}
|
||||
<template #append>
|
||||
<v-error :error="error" />
|
||||
<v-button small class="reset-preset" @click="resetPresetAndRefresh">
|
||||
{{ t('reset_page_preferences') }}
|
||||
</v-button>
|
||||
</template>
|
||||
</v-info>
|
||||
<v-info
|
||||
v-else-if="geojsonError"
|
||||
type="warning"
|
||||
icon="wrong_location"
|
||||
center
|
||||
:title="t('layouts.map.invalid_geometry')"
|
||||
>
|
||||
{{ geojsonError }}
|
||||
</v-info>
|
||||
<v-progress-circular v-else-if="loading || geojsonLoading" indeterminate x-large class="center" />
|
||||
<slot v-else-if="itemCount === 0 && (searchQuery || activeFilterCount > 0)" name="no-results" />
|
||||
</transition>
|
||||
|
||||
<template v-if="loading || itemCount > 0">
|
||||
<div class="footer">
|
||||
<div class="pagination">
|
||||
<v-pagination
|
||||
v-if="totalPages > 1"
|
||||
:length="totalPages"
|
||||
:total-visible="7"
|
||||
show-first-last
|
||||
:model-value="page"
|
||||
@update:model-value="toPage"
|
||||
/>
|
||||
</div>
|
||||
<div class="mapboxgl-ctrl-dropdown">
|
||||
<span>{{ t('limit') }}</span>
|
||||
<v-select
|
||||
:model-value="limit"
|
||||
:items="[
|
||||
{
|
||||
text: n(100),
|
||||
value: 100,
|
||||
},
|
||||
{
|
||||
text: n(1000),
|
||||
value: 1000,
|
||||
},
|
||||
{
|
||||
text: n(10000),
|
||||
value: 10000,
|
||||
},
|
||||
{
|
||||
text: n(100000),
|
||||
value: 100000,
|
||||
},
|
||||
]"
|
||||
inline
|
||||
@update:model-value="limit = +$event"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { defineComponent, toRefs } from 'vue';
|
||||
|
||||
import MapComponent from './components/map.vue';
|
||||
import { useLayoutState } from '@directus/shared/composables';
|
||||
|
||||
export default defineComponent({
|
||||
components: { MapComponent },
|
||||
setup() {
|
||||
const { t, n } = useI18n();
|
||||
const layoutState = useLayoutState();
|
||||
|
||||
const {
|
||||
loading,
|
||||
error,
|
||||
geojsonError,
|
||||
geometryOptions,
|
||||
geojson,
|
||||
featureId,
|
||||
selection,
|
||||
geojsonBounds,
|
||||
directusSource,
|
||||
directusLayers,
|
||||
handleClick,
|
||||
updateSelection,
|
||||
cameraOptions,
|
||||
resetPresetAndRefresh,
|
||||
geojsonLoading,
|
||||
itemCount,
|
||||
searchQuery,
|
||||
activeFilterCount,
|
||||
totalPages,
|
||||
page,
|
||||
toPage,
|
||||
limit,
|
||||
} = toRefs(layoutState.value);
|
||||
|
||||
return {
|
||||
t,
|
||||
loading,
|
||||
error,
|
||||
geojsonError,
|
||||
geometryOptions,
|
||||
geojson,
|
||||
featureId,
|
||||
selection,
|
||||
cameraOptions,
|
||||
geojsonBounds,
|
||||
directusSource,
|
||||
directusLayers,
|
||||
handleClick,
|
||||
updateSelection,
|
||||
resetPresetAndRefresh,
|
||||
geojsonLoading,
|
||||
itemCount,
|
||||
searchQuery,
|
||||
activeFilterCount,
|
||||
totalPages,
|
||||
page,
|
||||
toPage,
|
||||
limit,
|
||||
n,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.mapboxgl-ctrl-dropdown {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 36px;
|
||||
padding: 10px;
|
||||
color: var(--foreground-subdued);
|
||||
background-color: var(--background-subdued);
|
||||
border: var(--border-width) solid var(--background-subdued);
|
||||
border-radius: var(--border-radius);
|
||||
|
||||
span {
|
||||
width: auto;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.v-select {
|
||||
color: var(--foreground-normal);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
.layout-map .mapboxgl-map .mapboxgl-canvas-container {
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.layout-map .mapboxgl-map.loading .mapboxgl-canvas-container {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.layout-map .mapboxgl-map.error .mapboxgl-canvas-container {
|
||||
opacity: 0.4;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.layout-map {
|
||||
width: 100%;
|
||||
height: calc(100% - 65px);
|
||||
}
|
||||
|
||||
.center {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
-webkit-transform: translate(-50%, -50%);
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.v-progress-circular {
|
||||
--v-progress-circular-background-color: var(--primary-25);
|
||||
--v-progress-circular-color: var(--primary-75);
|
||||
}
|
||||
|
||||
.reset-preset {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.delete-action {
|
||||
--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);
|
||||
}
|
||||
|
||||
.custom-layers {
|
||||
padding: var(--content-padding);
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.v-info {
|
||||
padding: 40px;
|
||||
background-color: var(--background-page);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--card-shadow);
|
||||
}
|
||||
|
||||
.footer {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
left: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-sizing: border-box;
|
||||
padding-top: 40px;
|
||||
overflow: hidden;
|
||||
background-color: transparent !important;
|
||||
|
||||
.pagination {
|
||||
--v-button-height: 28px;
|
||||
|
||||
display: inline-block;
|
||||
|
||||
button {
|
||||
box-shadow: 0 0 2px 1px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
110
app/src/layouts/map/options.vue
Normal file
110
app/src/layouts/map/options.vue
Normal file
@@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<template v-if="geometryFields.length == 0">
|
||||
<div class="field">
|
||||
<v-input type="text" disabled :prefix="'No compatible fields'"></v-input>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="field">
|
||||
<div class="type-label">{{ t('layouts.map.field') }}</div>
|
||||
<v-select
|
||||
v-model="geometryField"
|
||||
:items="geometryFields.map(({ name, field }) => ({ text: name, value: field }))"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="field">
|
||||
<div class="type-label">{{ t('layouts.map.basemap') }}</div>
|
||||
<v-select v-model="basemap" :items="basemaps.map((s) => ({ text: s.name, value: s.name }))" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<v-checkbox
|
||||
v-model="fitDataToView"
|
||||
:label="t('layouts.map.fit_data')"
|
||||
:disabled="geometryOptions && geometryOptions.geometryFormat !== 'native'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<v-checkbox
|
||||
v-model="clusterData"
|
||||
:label="t('layouts.map.cluster')"
|
||||
:disabled="geometryOptions && geometryOptions.geometryType !== 'Point'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- <div class="field">
|
||||
<v-drawer
|
||||
v-model="customLayerDrawerOpen"
|
||||
:title="t('layouts.map.custom_layers')"
|
||||
@cancel="customLayerDrawerOpen = false"
|
||||
>
|
||||
<template #activator="{ on }">
|
||||
<v-button @click="on">{{ t('layouts.map.edit_custom_layers') }}</v-button>
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<v-button v-tooltip.bottom="t('reset')" icon rounded class="delete-action" @click="resetLayers">
|
||||
<v-icon name="replay" />
|
||||
</v-button>
|
||||
<v-button v-tooltip.bottom="t('save')" icon rounded @click="updateLayers">
|
||||
<v-icon name="check" />
|
||||
</v-button>
|
||||
</template>
|
||||
<div class="custom-layers">
|
||||
<interface-input-code v-model="customLayers" language="json" type="json" :line-number="false" />
|
||||
</div>
|
||||
</v-drawer>
|
||||
</div> -->
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { defineComponent, toRefs } from 'vue';
|
||||
|
||||
import { useLayoutState } from '@directus/shared/composables';
|
||||
import { useAppStore } from '@/stores';
|
||||
import { getBasemapSources } from '@/utils/geometry/basemap';
|
||||
|
||||
export default defineComponent({
|
||||
setup() {
|
||||
const { t } = useI18n();
|
||||
|
||||
const basemaps = getBasemapSources();
|
||||
const appStore = useAppStore();
|
||||
const { basemap } = toRefs(appStore);
|
||||
|
||||
const layoutState = useLayoutState();
|
||||
const {
|
||||
props,
|
||||
geometryFields,
|
||||
geometryField,
|
||||
fitDataToView,
|
||||
geometryOptions,
|
||||
clusterData,
|
||||
customLayerDrawerOpen,
|
||||
resetLayers,
|
||||
updateLayers,
|
||||
customLayers,
|
||||
} = toRefs(layoutState.value);
|
||||
|
||||
return {
|
||||
t,
|
||||
props,
|
||||
geometryFields,
|
||||
geometryField,
|
||||
fitDataToView,
|
||||
geometryOptions,
|
||||
clusterData,
|
||||
customLayerDrawerOpen,
|
||||
resetLayers,
|
||||
updateLayers,
|
||||
customLayers,
|
||||
basemaps,
|
||||
basemap,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
24
app/src/layouts/map/sidebar.vue
Normal file
24
app/src/layouts/map/sidebar.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<filter-sidebar-detail v-model="props.filters" :collection="props.collection" :loading="loading" />
|
||||
<export-sidebar-detail
|
||||
:layout-query="props.layoutQuery"
|
||||
:filters="props.filters"
|
||||
:search-query="props.searchQuery"
|
||||
:collection="props.collection"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, toRefs } from 'vue';
|
||||
|
||||
import { useLayoutState } from '@directus/shared/composables';
|
||||
|
||||
export default defineComponent({
|
||||
setup() {
|
||||
const layoutState = useLayoutState();
|
||||
const { props, loading } = toRefs(layoutState.value);
|
||||
|
||||
return { props, loading };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
81
app/src/layouts/map/style.ts
Normal file
81
app/src/layouts/map/style.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { AnyLayer, Expression } from 'maplibre-gl';
|
||||
|
||||
const baseColor = '#09f';
|
||||
const selectColor = '#FFA500';
|
||||
const fill: Expression = ['case', ['boolean', ['feature-state', 'selected'], false], selectColor, baseColor];
|
||||
const outline: Expression = [
|
||||
'case',
|
||||
['boolean', ['feature-state', 'selected'], false],
|
||||
selectColor,
|
||||
['boolean', ['feature-state', 'hovered'], false],
|
||||
selectColor,
|
||||
baseColor,
|
||||
];
|
||||
|
||||
export const layers: AnyLayer[] = [
|
||||
{
|
||||
id: '__directus_polygons',
|
||||
type: 'fill',
|
||||
source: '__directus',
|
||||
filter: ['all', ['!has', 'point_count'], ['==', '$type', 'Polygon']],
|
||||
paint: {
|
||||
'fill-color': fill,
|
||||
'fill-opacity': 0.15,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '__directus_polygons_outline',
|
||||
type: 'line',
|
||||
source: '__directus',
|
||||
filter: ['all', ['!has', 'point_count'], ['==', '$type', 'Polygon']],
|
||||
paint: {
|
||||
'line-color': outline,
|
||||
'line-width': 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '__directus_lines',
|
||||
type: 'line',
|
||||
source: '__directus',
|
||||
filter: ['all', ['!has', 'point_count'], ['==', '$type', 'LineString']],
|
||||
paint: {
|
||||
'line-color': outline,
|
||||
'line-width': 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '__directus_points',
|
||||
type: 'circle',
|
||||
source: '__directus',
|
||||
filter: ['all', ['!has', 'point_count'], ['==', '$type', 'Point']],
|
||||
layout: {},
|
||||
paint: {
|
||||
'circle-radius': 5,
|
||||
'circle-color': fill,
|
||||
'circle-stroke-color': outline,
|
||||
'circle-stroke-width': 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '__directus_clusters',
|
||||
type: 'circle',
|
||||
source: '__directus',
|
||||
filter: ['has', 'point_count'],
|
||||
paint: {
|
||||
'circle-color': ['step', ['get', 'point_count'], '#51bbd6', 100, '#f1f075', 750, '#f28cb1'],
|
||||
'circle-radius': ['step', ['get', 'point_count'], 20, 100, 30, 750, 40],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '__directus_cluster_count',
|
||||
type: 'symbol',
|
||||
source: '__directus',
|
||||
filter: ['has', 'point_count'],
|
||||
layout: {
|
||||
'text-field': '{point_count_abbreviated}',
|
||||
// 'text-font': ['Open Sans Semibold'],
|
||||
'text-font': ['Noto Sans Regular'],
|
||||
'text-size': ['step', ['get', 'point_count'], 15, 100, 17, 750, 19],
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -1,6 +1,10 @@
|
||||
<template>
|
||||
<collections-not-found v-if="!currentCollection || collection.startsWith('directus_')" />
|
||||
<private-view v-else :title="bookmark ? bookmarkTitle : currentCollection.name">
|
||||
<private-view
|
||||
v-else
|
||||
:title="bookmark ? bookmarkTitle : currentCollection.name"
|
||||
:small-header="currentLayout.smallHeader"
|
||||
>
|
||||
<template #title-outer:prepend>
|
||||
<v-button class="header-icon" rounded icon secondary disabled>
|
||||
<v-icon :name="currentCollection.icon" :color="currentCollection.color" />
|
||||
@@ -252,6 +256,7 @@ import { useRouter } from 'vue-router';
|
||||
import { usePermissionsStore, useUserStore } from '@/stores';
|
||||
import DrawerBatch from '@/views/private/components/drawer-batch';
|
||||
import { unexpectedError } from '@/utils/unexpected-error';
|
||||
import { getLayouts } from '@/layouts';
|
||||
|
||||
type Item = {
|
||||
[field: string]: any;
|
||||
@@ -285,6 +290,7 @@ export default defineComponent({
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const { layouts } = getLayouts();
|
||||
const userStore = useUserStore();
|
||||
const permissionsStore = usePermissionsStore();
|
||||
|
||||
@@ -342,6 +348,8 @@ export default defineComponent({
|
||||
|
||||
const { bookmarkDialogActive, creatingBookmark, createBookmark, editingBookmark, editBookmark } = useBookmarks();
|
||||
|
||||
const currentLayout = computed(() => layouts.value.find((l) => l.id === layout.value));
|
||||
|
||||
watch(
|
||||
collection,
|
||||
() => {
|
||||
@@ -395,6 +403,7 @@ export default defineComponent({
|
||||
clearLocalSave,
|
||||
refresh,
|
||||
refreshInterval,
|
||||
currentLayout,
|
||||
};
|
||||
|
||||
function refresh() {
|
||||
|
||||
@@ -34,7 +34,22 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template v-if="['decimal', 'float'].includes(fieldData.type) === false">
|
||||
<template v-if="fieldData.type == 'geometry'">
|
||||
<template v-if="fieldData.schema">
|
||||
<div class="field half-right">
|
||||
<div class="label type-label">{{ t('interfaces.map.geometry_type') }}</div>
|
||||
<v-select
|
||||
v-model="fieldData.schema.geometry_type"
|
||||
:show-deselect="true"
|
||||
:placeholder="t('any')"
|
||||
:disabled="isExisting"
|
||||
:items="GEOMETRY_TYPES.map((value) => ({ value, text: value }))"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-else-if="['decimal', 'float'].includes(fieldData.type) === false">
|
||||
<div v-if="fieldData.schema" class="field half">
|
||||
<div class="label type-label">{{ t('length') }}</div>
|
||||
<v-input
|
||||
@@ -164,9 +179,13 @@
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { defineComponent, computed } from 'vue';
|
||||
import { state } from '../store';
|
||||
import { GEOMETRY_TYPES } from '@directus/shared/constants';
|
||||
import { translate } from '@/utils/translate-object-values';
|
||||
|
||||
export const fieldTypes = [
|
||||
import { Type } from '@directus/shared/types';
|
||||
import { TranslateResult } from 'vue-i18n';
|
||||
|
||||
export const fieldTypes: Array<{ value: Type; text: TranslateResult | string } | { divider: true }> = [
|
||||
{
|
||||
text: '$t:string',
|
||||
value: 'string',
|
||||
@@ -198,6 +217,11 @@ export const fieldTypes = [
|
||||
value: 'decimal',
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
text: '$t:geometry',
|
||||
value: 'geometry',
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
text: '$t:timestamp',
|
||||
value: 'timestamp',
|
||||
@@ -286,6 +310,7 @@ export default defineComponent({
|
||||
t,
|
||||
fieldData: state.fieldData,
|
||||
typesWithLabels,
|
||||
GEOMETRY_TYPES,
|
||||
typeDisabled,
|
||||
typePlaceholder,
|
||||
defaultValue,
|
||||
|
||||
@@ -1068,6 +1068,7 @@ function initLocalStore(collection: string, field: string, type: LocalType): voi
|
||||
default_value: undefined,
|
||||
max_length: undefined,
|
||||
is_nullable: true,
|
||||
geometry_type: undefined,
|
||||
};
|
||||
|
||||
switch (state.fieldData.type) {
|
||||
@@ -1088,6 +1089,9 @@ function initLocalStore(collection: string, field: string, type: LocalType): voi
|
||||
state.fieldData.schema.default_value = false;
|
||||
state.fieldData.schema.is_nullable = false;
|
||||
break;
|
||||
case 'geometry':
|
||||
state.fieldData.meta.special = ['geometry'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -1114,6 +1118,7 @@ function clearLocalStore(): void {
|
||||
is_unique: false,
|
||||
numeric_precision: null,
|
||||
numeric_scale: null,
|
||||
geometry_type: undefined,
|
||||
},
|
||||
meta: {
|
||||
hidden: false,
|
||||
|
||||
@@ -66,6 +66,7 @@ import { useFieldsStore } from '@/stores/';
|
||||
import FieldSelect from './field-select.vue';
|
||||
import hideDragImage from '@/utils/hide-drag-image';
|
||||
import { orderBy, isNil } from 'lodash';
|
||||
import { LocalType } from '@directus/shared/types';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'FieldsManagement',
|
||||
@@ -97,7 +98,7 @@ export default defineComponent({
|
||||
return parsedFields.value.filter((field) => field.meta?.system !== true);
|
||||
});
|
||||
|
||||
const addOptions = computed(() => [
|
||||
const addOptions = computed<Array<{ type: LocalType; icon: string; text: any } | { divider: boolean }>>(() => [
|
||||
{
|
||||
type: 'standard',
|
||||
icon: 'create',
|
||||
|
||||
@@ -8,5 +8,6 @@ export const useAppStore = defineStore({
|
||||
hydrating: false,
|
||||
error: null,
|
||||
authenticated: false,
|
||||
basemap: 'OpenStreetMap',
|
||||
}),
|
||||
});
|
||||
|
||||
113
app/src/utils/geometry/basemap.ts
Normal file
113
app/src/utils/geometry/basemap.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { Style, RasterSource } from 'maplibre-gl';
|
||||
import getSetting from '@/utils/get-setting';
|
||||
import maplibre from 'maplibre-gl';
|
||||
import { getTheme } from '@/utils/get-theme';
|
||||
|
||||
export type BasemapSource = {
|
||||
name: string;
|
||||
type: 'raster' | 'tile' | 'style';
|
||||
url: string;
|
||||
};
|
||||
|
||||
const defaultBasemap: BasemapSource = {
|
||||
name: 'OpenStreetMap',
|
||||
type: 'raster',
|
||||
url: 'https://{a-c}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
};
|
||||
|
||||
const baseStyle: Style = {
|
||||
version: 8,
|
||||
glyphs:
|
||||
'https://basemaps.arcgis.com/arcgis/rest/services/OpenStreetMap_GCS_v2/VectorTileServer/resources/fonts/{fontstack}/{range}.pbf',
|
||||
};
|
||||
|
||||
export function getBasemapSources(): BasemapSource[] {
|
||||
if (getSetting('mapbox_key')) {
|
||||
return [getDefaultMapboxBasemap(), defaultBasemap, ...(getSetting('basemaps') || [])];
|
||||
}
|
||||
|
||||
return [defaultBasemap, ...(getSetting('basemaps') || [])];
|
||||
}
|
||||
|
||||
export function getStyleFromBasemapSource(basemap: BasemapSource): Style | string {
|
||||
setMapboxAccessToken(basemap.url);
|
||||
|
||||
if (basemap.type == 'style') {
|
||||
return basemap.url;
|
||||
} else {
|
||||
const style: Style = { ...baseStyle };
|
||||
const source: RasterSource = { type: 'raster' };
|
||||
if (basemap.type == 'raster') {
|
||||
source.tiles = expandUrl(basemap.url);
|
||||
}
|
||||
if (basemap.type == 'tile') {
|
||||
source.url = basemap.url;
|
||||
}
|
||||
style.layers = [{ id: basemap.name, source: basemap.name, type: 'raster' }];
|
||||
style.sources = { [basemap.name]: source };
|
||||
return style;
|
||||
}
|
||||
}
|
||||
|
||||
function expandUrl(url: string): string[] {
|
||||
const urls = [];
|
||||
let match = /\{([a-z])-([a-z])\}/.exec(url);
|
||||
if (match) {
|
||||
// char range
|
||||
const startCharCode = match[1].charCodeAt(0);
|
||||
const stopCharCode = match[2].charCodeAt(0);
|
||||
let charCode;
|
||||
for (charCode = startCharCode; charCode <= stopCharCode; ++charCode) {
|
||||
urls.push(url.replace(match[0], String.fromCharCode(charCode)));
|
||||
}
|
||||
return urls;
|
||||
}
|
||||
match = /\{(\d+)-(\d+)\}/.exec(url);
|
||||
if (match) {
|
||||
// number range
|
||||
const stop = parseInt(match[2], 10);
|
||||
for (let i = parseInt(match[1], 10); i <= stop; i++) {
|
||||
urls.push(url.replace(match[0], i.toString()));
|
||||
}
|
||||
return urls;
|
||||
}
|
||||
match = /\{(([a-z0-9]+)(,([a-z0-9]+))+)\}/.exec(url);
|
||||
if (match) {
|
||||
// csv
|
||||
const subdomains = match[1].split(',');
|
||||
for (const subdomain of subdomains) {
|
||||
urls.push(url.replace(match[0], subdomain));
|
||||
}
|
||||
return urls;
|
||||
}
|
||||
urls.push(url);
|
||||
return urls;
|
||||
}
|
||||
|
||||
function setMapboxAccessToken(styleURL: string): void {
|
||||
styleURL = styleURL.replace(/^mapbox:\//, 'https://api.mapbox.com/styles/v1');
|
||||
|
||||
try {
|
||||
const url = new URL(styleURL);
|
||||
if (url.host == 'api.mapbox.com') {
|
||||
const token = url.searchParams.get('access_token');
|
||||
if (token) maplibre.accessToken = token;
|
||||
}
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function getDefaultMapboxBasemap(): BasemapSource {
|
||||
const defaultMapboxBasemap: BasemapSource = {
|
||||
name: 'Mapbox',
|
||||
type: 'style',
|
||||
url: 'mapbox://styles/mapbox/light-v10',
|
||||
};
|
||||
|
||||
if (getTheme() === 'dark') {
|
||||
defaultMapboxBasemap.url = 'mapbox://styles/mapbox/dark-v10';
|
||||
}
|
||||
|
||||
return defaultMapboxBasemap;
|
||||
}
|
||||
193
app/src/utils/geometry/controls.ts
Normal file
193
app/src/utils/geometry/controls.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { Map, Point } from 'maplibre-gl';
|
||||
|
||||
export class ButtonControl {
|
||||
active: boolean;
|
||||
element: HTMLElement;
|
||||
groupElement?: HTMLElement;
|
||||
constructor(private className: string, private callback: (...args: any) => any) {
|
||||
this.element = document.createElement('button');
|
||||
this.element.className = this.className;
|
||||
this.element.onclick = callback;
|
||||
this.active = false;
|
||||
}
|
||||
click(...args: any[]): void {
|
||||
this.callback(...args);
|
||||
}
|
||||
activate(yes: boolean): void {
|
||||
this.element.classList[yes ? 'add' : 'remove']('active');
|
||||
this.active = yes;
|
||||
}
|
||||
show(yes: boolean): void {
|
||||
this.element.classList[yes ? 'remove' : 'add']('hidden');
|
||||
}
|
||||
onAdd(): HTMLElement {
|
||||
this.groupElement = document.createElement('div');
|
||||
this.groupElement.className = 'mapboxgl-ctrl mapboxgl-ctrl-group';
|
||||
this.groupElement.appendChild(this.element);
|
||||
return this.groupElement;
|
||||
}
|
||||
onRemove(): void {
|
||||
this.element.remove();
|
||||
this.groupElement?.remove();
|
||||
}
|
||||
}
|
||||
|
||||
type BoxSelectControlOptions = {
|
||||
groupElementClass?: string;
|
||||
boxElementClass?: string;
|
||||
selectButtonClass?: string;
|
||||
unselectButtonClass?: string;
|
||||
layers: string[];
|
||||
};
|
||||
|
||||
export class BoxSelectControl {
|
||||
groupElement: HTMLElement;
|
||||
boxElement: HTMLElement;
|
||||
|
||||
selectButton: ButtonControl;
|
||||
unselectButton: ButtonControl;
|
||||
|
||||
map?: Map & { fire: (event: string, data?: any) => void };
|
||||
layers: string[];
|
||||
|
||||
selecting = false;
|
||||
shiftPressed = false;
|
||||
startPos: Point | undefined;
|
||||
lastPos: Point | undefined;
|
||||
|
||||
onKeyDownHandler: (event: KeyboardEvent) => any;
|
||||
onKeyUpHandler: (event: KeyboardEvent) => any;
|
||||
onMouseDownHandler: (event: MouseEvent) => any;
|
||||
onMouseMoveHandler: (event: MouseEvent) => any;
|
||||
onMouseUpHandler: (event: MouseEvent) => any;
|
||||
|
||||
constructor(options: BoxSelectControlOptions) {
|
||||
this.layers = options?.layers ?? [];
|
||||
this.boxElement = document.createElement('div');
|
||||
this.boxElement.className = options?.boxElementClass ?? 'selection-box';
|
||||
this.groupElement = document.createElement('div');
|
||||
this.groupElement.className = options?.groupElementClass ?? 'mapboxgl-ctrl mapboxgl-ctrl-group';
|
||||
this.selectButton = new ButtonControl(options?.selectButtonClass ?? 'ctrl-select', () => {
|
||||
this.activate(!this.shiftPressed);
|
||||
});
|
||||
this.unselectButton = new ButtonControl(options?.unselectButtonClass ?? 'ctrl-unselect', () => {
|
||||
this.reset();
|
||||
this.activate(false);
|
||||
this.map!.fire('select.end');
|
||||
});
|
||||
this.groupElement.appendChild(this.selectButton.element);
|
||||
this.groupElement.appendChild(this.unselectButton.element);
|
||||
|
||||
this.onKeyDownHandler = this.onKeyDown.bind(this);
|
||||
this.onKeyUpHandler = this.onKeyUp.bind(this);
|
||||
this.onMouseDownHandler = this.onMouseDown.bind(this);
|
||||
this.onMouseMoveHandler = this.onMouseMove.bind(this);
|
||||
this.onMouseUpHandler = this.onMouseUp.bind(this);
|
||||
}
|
||||
|
||||
onAdd(map: Map): HTMLElement {
|
||||
this.map = map as any;
|
||||
this.map!.boxZoom.disable();
|
||||
this.map!.getContainer().appendChild(this.boxElement);
|
||||
this.map!.getContainer().addEventListener('pointerdown', this.onMouseDownHandler, true);
|
||||
document.addEventListener('keydown', this.onKeyDownHandler);
|
||||
document.addEventListener('keyup', this.onKeyUpHandler);
|
||||
return this.groupElement;
|
||||
}
|
||||
|
||||
onRemove(): void {
|
||||
this.map!.boxZoom.enable();
|
||||
this.boxElement.remove();
|
||||
this.groupElement.remove();
|
||||
this.map!.getContainer().removeEventListener('pointerdown', this.onMouseDownHandler);
|
||||
document.removeEventListener('keydown', this.onKeyDownHandler);
|
||||
document.removeEventListener('keyup', this.onKeyUpHandler);
|
||||
}
|
||||
|
||||
active(): boolean {
|
||||
return this.shiftPressed || this.selecting;
|
||||
}
|
||||
|
||||
getMousePosition(event: MouseEvent): Point {
|
||||
const container = this.map!.getContainer();
|
||||
const rect = container.getBoundingClientRect();
|
||||
// @ts-ignore
|
||||
return new Point(event.clientX - rect.left - container.clientLeft, event.clientY - rect.top - container.clientTop);
|
||||
// return {
|
||||
// x: event.clientX - rect.left - container.clientLeft,
|
||||
// y: event.clientY - rect.top - container.clientTop
|
||||
// };
|
||||
}
|
||||
|
||||
onKeyDown(event: KeyboardEvent): void {
|
||||
if (event.key == 'Shift') {
|
||||
this.activate(true);
|
||||
}
|
||||
if (event.key == 'Escape') {
|
||||
this.reset();
|
||||
this.activate(false);
|
||||
this.map!.fire('select.end');
|
||||
}
|
||||
}
|
||||
|
||||
activate(yes: boolean): void {
|
||||
this.shiftPressed = yes;
|
||||
this.selectButton.activate(yes);
|
||||
this.map!.fire(`select.${yes ? 'enable' : 'disable'}`);
|
||||
}
|
||||
|
||||
onKeyUp(event: KeyboardEvent): void {
|
||||
if (event.key == 'Shift') {
|
||||
this.activate(false);
|
||||
}
|
||||
}
|
||||
|
||||
onMouseDown(event: MouseEvent): void {
|
||||
if (!this.shiftPressed) {
|
||||
return;
|
||||
}
|
||||
if (event.button === 0) {
|
||||
this.selecting = true;
|
||||
this.map!.dragPan.disable();
|
||||
this.startPos = this.getMousePosition(event);
|
||||
this.lastPos = this.startPos;
|
||||
document.addEventListener('pointermove', this.onMouseMoveHandler);
|
||||
document.addEventListener('pointerup', this.onMouseUpHandler);
|
||||
this.map!.fire('select.start');
|
||||
}
|
||||
}
|
||||
|
||||
onMouseMove(event: MouseEvent): void {
|
||||
this.lastPos = this.getMousePosition(event);
|
||||
const minX = Math.min(this.startPos!.x, this.lastPos!.x),
|
||||
maxX = Math.max(this.startPos!.x, this.lastPos!.x),
|
||||
minY = Math.min(this.startPos!.y, this.lastPos!.y),
|
||||
maxY = Math.max(this.startPos!.y, this.lastPos!.y);
|
||||
const transform = `translate(${minX}px, ${minY}px)`;
|
||||
const width = maxX - minX + 'px';
|
||||
const height = maxY - minY + 'px';
|
||||
this.updateBoxStyle({ transform, width, height });
|
||||
}
|
||||
|
||||
onMouseUp(): void {
|
||||
this.reset();
|
||||
const features = this.map!.queryRenderedFeatures([this.startPos!, this.lastPos!], {
|
||||
layers: this.layers,
|
||||
});
|
||||
this.map!.fire('select.end', { features });
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.selecting = false;
|
||||
this.updateBoxStyle({ width: '0', height: '0', transform: '' });
|
||||
document.removeEventListener('pointermove', this.onMouseMoveHandler);
|
||||
document.removeEventListener('pointerup', this.onMouseUpHandler);
|
||||
this.map!.dragPan.enable();
|
||||
}
|
||||
|
||||
updateBoxStyle(style: { width: string; height: string; transform: string }): void {
|
||||
this.boxElement.style.transform = style.transform;
|
||||
this.boxElement.style.width = style.width;
|
||||
this.boxElement.style.height = style.height;
|
||||
}
|
||||
}
|
||||
130
app/src/utils/geometry/index.ts
Normal file
130
app/src/utils/geometry/index.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import {
|
||||
Type,
|
||||
GeometryFormat,
|
||||
Coordinate,
|
||||
AnyGeometry,
|
||||
GeometryOptions,
|
||||
GeoJSONSerializer,
|
||||
AllGeoJSON,
|
||||
GeoJSONParser,
|
||||
SimpleGeometry,
|
||||
} from '@directus/shared/types';
|
||||
import { BBox, Point, Feature, FeatureCollection } from 'geojson';
|
||||
import { coordEach } from '@turf/meta';
|
||||
import { i18n } from '@/lang';
|
||||
import { parse as wktToGeoJSON, stringify as geojsonToWKT } from 'wellknown';
|
||||
import { renderStringTemplate } from '@/utils/render-string-template';
|
||||
|
||||
export function expandBBox(bbox: BBox, coord: Coordinate): BBox {
|
||||
return [
|
||||
Math.min(bbox[0], coord[0]),
|
||||
Math.min(bbox[1], coord[1]),
|
||||
Math.max(bbox[2], coord[0]),
|
||||
Math.max(bbox[3], coord[1]),
|
||||
];
|
||||
}
|
||||
|
||||
export function getBBox(object: AnyGeometry): BBox {
|
||||
let bbox: BBox = [Infinity, Infinity, -Infinity, -Infinity];
|
||||
coordEach(object as AllGeoJSON, (coord) => {
|
||||
bbox = expandBBox(bbox, coord as Coordinate);
|
||||
});
|
||||
return bbox;
|
||||
}
|
||||
|
||||
export function getGeometryFormatForType(type: Type): GeometryFormat | undefined {
|
||||
switch (type) {
|
||||
case 'geometry':
|
||||
return 'native';
|
||||
case 'json':
|
||||
return 'geojson';
|
||||
case 'text':
|
||||
case 'string':
|
||||
return 'wkt';
|
||||
case 'csv':
|
||||
return 'lnglat';
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function getSerializer(options: GeometryOptions): GeoJSONSerializer {
|
||||
const { geometryFormat } = options;
|
||||
switch (geometryFormat) {
|
||||
case 'native':
|
||||
case 'geojson':
|
||||
return (entry) => entry;
|
||||
case 'wkt':
|
||||
return (entry) => geojsonToWKT(entry);
|
||||
case 'lnglat':
|
||||
return (entry) => (entry as Point).coordinates;
|
||||
default:
|
||||
throw new Error(i18n.global.t('interfaces.map.invalid_format', { format: geometryFormat }) as string);
|
||||
}
|
||||
}
|
||||
|
||||
export function getGeometryParser(options: GeometryOptions): (geom: any) => AnyGeometry {
|
||||
const { geometryFormat } = options;
|
||||
switch (geometryFormat) {
|
||||
case 'native':
|
||||
case 'geojson':
|
||||
return (geom) => geom as AnyGeometry;
|
||||
case 'wkt':
|
||||
return (geom) => wktToGeoJSON(geom) as AnyGeometry;
|
||||
case 'lnglat':
|
||||
return (geom) => ({ type: 'Point', coordinates: [Number(geom[0]), Number(geom[1])] } as AnyGeometry);
|
||||
default:
|
||||
throw new Error(i18n.global.t('interfaces.map.invalid_format', { format: geometryFormat }) as string);
|
||||
}
|
||||
}
|
||||
|
||||
export function getParser(options: GeometryOptions): GeoJSONParser {
|
||||
const parse = getGeometryParser(options);
|
||||
|
||||
return function (entry: any) {
|
||||
const geomRaw = entry[options.geometryField];
|
||||
const geom = geomRaw && parse(geomRaw);
|
||||
if (!geom) return undefined;
|
||||
geom.bbox = getBBox(geom);
|
||||
return geom;
|
||||
};
|
||||
}
|
||||
|
||||
export function toGeoJSON(entries: any[], options: GeometryOptions, template: string): FeatureCollection {
|
||||
const parser = getParser(options);
|
||||
|
||||
const geojson: FeatureCollection = {
|
||||
type: 'FeatureCollection',
|
||||
features: [],
|
||||
bbox: [Infinity, Infinity, -Infinity, -Infinity],
|
||||
};
|
||||
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const geometry = parser(entries[i]);
|
||||
if (!geometry) continue;
|
||||
const [a, b, c, d] = geometry.bbox!;
|
||||
geojson.bbox = expandBBox(geojson.bbox!, [a, b]);
|
||||
geojson.bbox = expandBBox(geojson.bbox!, [c, d]);
|
||||
const properties = { ...entries[i] };
|
||||
delete properties[options.geometryField];
|
||||
properties.description = renderStringTemplate(template, entries[i]).displayValue;
|
||||
const feature = { type: 'Feature', properties, geometry };
|
||||
geojson.features.push(feature as Feature);
|
||||
}
|
||||
if (geojson.features.length == 0) {
|
||||
delete geojson.bbox;
|
||||
}
|
||||
return geojson;
|
||||
}
|
||||
|
||||
export function flatten(geometry?: AnyGeometry): SimpleGeometry[] {
|
||||
if (!geometry) return [];
|
||||
if (geometry.type == 'GeometryCollection') {
|
||||
return geometry.geometries.flatMap(flatten);
|
||||
}
|
||||
if (geometry.type.startsWith('Multi')) {
|
||||
const type = geometry.type.replace('Multi', '');
|
||||
return (geometry.coordinates as any).map((coordinates: any) => ({ type, coordinates } as SimpleGeometry));
|
||||
}
|
||||
return [geometry as SimpleGeometry];
|
||||
}
|
||||
@@ -19,6 +19,7 @@ const defaultDisplayMap: Record<Type, string> = {
|
||||
unknown: 'raw',
|
||||
csv: 'labels',
|
||||
hash: 'formatted-value',
|
||||
geometry: 'map',
|
||||
};
|
||||
|
||||
export function getDefaultDisplayForType(type: Type): string {
|
||||
|
||||
@@ -19,6 +19,7 @@ const defaultInterfaceMap: Record<Type, string> = {
|
||||
unknown: 'input',
|
||||
csv: 'tags',
|
||||
hash: 'input-hash',
|
||||
geometry: 'map',
|
||||
};
|
||||
|
||||
export function getDefaultInterfaceForType(type: Type): string {
|
||||
|
||||
7
app/src/utils/get-setting.ts
Normal file
7
app/src/utils/get-setting.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { useSettingsStore } from '@/stores';
|
||||
|
||||
export default function getSetting(setting: string): any {
|
||||
const settingsStore = useSettingsStore();
|
||||
if (settingsStore.settings && setting in settingsStore.settings) return settingsStore.settings[setting];
|
||||
return null;
|
||||
}
|
||||
17
app/src/utils/get-theme.ts
Normal file
17
app/src/utils/get-theme.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useUserStore } from '@/stores';
|
||||
|
||||
export function getTheme(): 'light' | 'dark' {
|
||||
const userStore = useUserStore();
|
||||
|
||||
if (!userStore.currentUser) return 'light';
|
||||
|
||||
if (userStore.currentUser.theme === 'auto') {
|
||||
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
return 'dark';
|
||||
}
|
||||
|
||||
return 'light';
|
||||
}
|
||||
|
||||
return userStore.currentUser.theme as 'light' | 'dark';
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { render } from 'micromustache';
|
||||
import { computed, ComputedRef, Ref } from 'vue';
|
||||
import { computed, ComputedRef, Ref, unref } from 'vue';
|
||||
import { getFieldsFromTemplate } from './get-fields-from-template';
|
||||
|
||||
type StringTemplate = {
|
||||
@@ -9,17 +9,23 @@ type StringTemplate = {
|
||||
|
||||
export function renderStringTemplate(
|
||||
template: Ref<string | null> | string,
|
||||
item: Ref<Record<string, any> | undefined | null>
|
||||
item: Record<string, any> | undefined | null | Ref<Record<string, any> | undefined | null>
|
||||
): StringTemplate {
|
||||
const templateString = computed(() => (typeof template === 'string' ? template : template.value));
|
||||
const values = unref(item);
|
||||
|
||||
const fieldsInTemplate = computed(() => getFieldsFromTemplate(templateString.value));
|
||||
for (const key in values) {
|
||||
if (typeof values[key] === 'object') values[key] = JSON.stringify(values[key]);
|
||||
}
|
||||
|
||||
const templateString = unref(template);
|
||||
|
||||
const fieldsInTemplate = computed(() => getFieldsFromTemplate(templateString));
|
||||
|
||||
const displayValue = computed(() => {
|
||||
if (!item.value || !templateString.value || !fieldsInTemplate.value) return false;
|
||||
if (!values || !templateString || !fieldsInTemplate.value) return false;
|
||||
|
||||
try {
|
||||
return render(templateString.value, item.value, { propsExist: true });
|
||||
return render(templateString, values, { propsExist: true });
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -57,6 +57,12 @@ export default function getAvailableOperatorsForType(type: Type): OperatorType {
|
||||
type,
|
||||
operators: ['eq', 'neq', 'lt', 'lte', 'gt', 'gte', 'between', 'nbetween', 'empty', 'nempty', 'in', 'nin'],
|
||||
};
|
||||
// Geometry
|
||||
case 'geometry':
|
||||
return {
|
||||
type,
|
||||
operators: ['eq', 'neq', 'intersects', 'nintersects', 'intersects_bbox', 'nintersects_bbox'],
|
||||
};
|
||||
default:
|
||||
return {
|
||||
type,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<header ref="headerEl" class="header-bar" :class="{ collapsed: collapsed }">
|
||||
<header ref="headerEl" class="header-bar" :class="{ collapsed, small }">
|
||||
<v-button secondary class="nav-toggle" icon rounded @click="$emit('primary')">
|
||||
<v-icon :name="primaryActionIcon" />
|
||||
</v-button>
|
||||
@@ -55,6 +55,10 @@ export default defineComponent({
|
||||
type: String,
|
||||
default: 'menu',
|
||||
},
|
||||
small: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['primary', 'toggle:sidebar'],
|
||||
setup() {
|
||||
@@ -97,7 +101,7 @@ export default defineComponent({
|
||||
padding: 0 12px;
|
||||
background-color: var(--background-page);
|
||||
box-shadow: 0;
|
||||
transition: box-shadow var(--medium) var(--transition);
|
||||
transition: box-shadow var(--medium) var(--transition), margin var(--fast) var(--transition);
|
||||
|
||||
.nav-toggle {
|
||||
@media (min-width: 960px) {
|
||||
@@ -167,6 +171,11 @@ export default defineComponent({
|
||||
}
|
||||
}
|
||||
|
||||
&.small .title-container .headline {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&.collapsed {
|
||||
box-shadow: 0 4px 7px -4px rgba(0, 0, 0, 0.2);
|
||||
|
||||
@@ -192,8 +201,11 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
@media (min-width: 600px) {
|
||||
margin: 24px 0;
|
||||
padding: 0 32px;
|
||||
|
||||
&:not(.small) {
|
||||
margin: 24px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
</aside>
|
||||
<div ref="contentEl" class="content">
|
||||
<header-bar
|
||||
:small="smallHeader"
|
||||
show-sidebar-toggle
|
||||
:title="title"
|
||||
@toggle:sidebar="sidebarOpen = !sidebarOpen"
|
||||
@@ -89,6 +90,10 @@ export default defineComponent({
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
smallHeader: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
version: '3.1'
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:13-alpine
|
||||
image: postgis/postgis:13-3.1-alpine
|
||||
environment:
|
||||
POSTGRES_PASSWORD: secret
|
||||
POSTGRES_DB: directus
|
||||
|
||||
1535
package-lock.json
generated
1535
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,22 +1,22 @@
|
||||
import KnexPostgres, { parseDefaultValue } from 'knex-schema-inspector/dist/dialects/postgres';
|
||||
import { Column } from 'knex-schema-inspector/dist/types/column';
|
||||
import { SchemaOverview } from '../types/overview';
|
||||
import { SchemaInspector } from '../types/schema';
|
||||
|
||||
export default class Postgres extends KnexPostgres implements SchemaInspector {
|
||||
async overview(): Promise<SchemaOverview> {
|
||||
const [columnsResult, primaryKeysResult] = await Promise.all([
|
||||
const [columnsResult, primaryKeysResult, geometryColumnsResult] = await Promise.all([
|
||||
// Only select columns from BASE TABLEs to exclude views (Postgres views
|
||||
// cannot have primary keys so they cannot be used)
|
||||
this.knex.raw(
|
||||
`
|
||||
SELECT
|
||||
c.table_name,
|
||||
c.column_name,
|
||||
c.column_default as default_value,
|
||||
c.is_nullable,
|
||||
c.data_type,
|
||||
c.character_maximum_length as max_length,
|
||||
c.is_identity
|
||||
SELECT c.table_name
|
||||
, c.column_name
|
||||
, c.column_default as default_value
|
||||
, c.data_type
|
||||
, c.character_maximum_length as max_length
|
||||
, CASE WHEN c.is_identity = 'YES' THEN true ELSE false END is_identity
|
||||
, CASE WHEN c.is_nullable = 'YES' THEN true ELSE false END is_nullable
|
||||
FROM
|
||||
information_schema.columns c
|
||||
LEFT JOIN information_schema.tables t
|
||||
@@ -30,14 +30,12 @@ export default class Postgres extends KnexPostgres implements SchemaInspector {
|
||||
|
||||
this.knex.raw(
|
||||
`
|
||||
SELECT
|
||||
relname as table_name,
|
||||
pg_attribute.attname as column_name
|
||||
FROM
|
||||
pg_index,
|
||||
pg_class,
|
||||
pg_attribute,
|
||||
pg_namespace
|
||||
SELECT relname as table_name
|
||||
, pg_attribute.attname as column_name
|
||||
FROM pg_index
|
||||
, pg_class
|
||||
, pg_attribute
|
||||
, pg_namespace
|
||||
WHERE
|
||||
indrelid = pg_class.oid
|
||||
AND nspname IN (?)
|
||||
@@ -48,32 +46,113 @@ export default class Postgres extends KnexPostgres implements SchemaInspector {
|
||||
`,
|
||||
[this.explodedSchema.join(',')]
|
||||
),
|
||||
this.knex
|
||||
.raw(
|
||||
`
|
||||
WITH geometries as (
|
||||
select * from geometry_columns
|
||||
union
|
||||
select * from geography_columns
|
||||
)
|
||||
SELECT f_table_name as table_name
|
||||
, f_geometry_column as column_name
|
||||
, type as data_type
|
||||
FROM geometries
|
||||
WHERE f_table_schema in (?)
|
||||
`,
|
||||
[this.explodedSchema.join(',')]
|
||||
)
|
||||
.catch(() => undefined),
|
||||
]);
|
||||
|
||||
const columns = columnsResult.rows;
|
||||
const primaryKeys = primaryKeysResult.rows;
|
||||
const geometryColumns = geometryColumnsResult?.rows || [];
|
||||
|
||||
const overview: SchemaOverview = {};
|
||||
|
||||
for (const column of columns) {
|
||||
if (column.table_name in overview === false)
|
||||
overview[column.table_name] = {
|
||||
primary: primaryKeys.find(
|
||||
(key: { table_name: string; column_name: string }) => key.table_name === column.table_name
|
||||
)?.column_name,
|
||||
columns: {},
|
||||
};
|
||||
if (column.is_identity || column.default_value?.startsWith('nextval(')) {
|
||||
column.default_value = 'AUTO_INCREMENT';
|
||||
} else {
|
||||
column.default_value = parseDefaultValue(column.default_value);
|
||||
}
|
||||
|
||||
overview[column.table_name].columns[column.column_name] = {
|
||||
...column,
|
||||
default_value:
|
||||
column.is_identity === 'YES' || column.default_value?.startsWith('nextval(')
|
||||
? 'AUTO_INCREMENT'
|
||||
: parseDefaultValue(column.default_value),
|
||||
is_nullable: column.is_nullable === 'YES',
|
||||
};
|
||||
if (column.table_name in overview === false) {
|
||||
overview[column.table_name] = { columns: {}, primary: <any>undefined };
|
||||
}
|
||||
overview[column.table_name].columns[column.column_name] = column;
|
||||
}
|
||||
|
||||
for (const { table_name, column_name } of primaryKeys) {
|
||||
overview[table_name].primary = column_name;
|
||||
}
|
||||
for (const { table_name, column_name, data_type } of geometryColumns) {
|
||||
overview[table_name].columns[column_name].data_type = data_type;
|
||||
}
|
||||
|
||||
return overview;
|
||||
}
|
||||
|
||||
// This is required as PostGIS data types are not accessible from the
|
||||
// information_schema. We have to fetch them from geography_columns
|
||||
columnInfo(): Promise<Column[]>;
|
||||
columnInfo(table: string): Promise<Column[]>;
|
||||
columnInfo(table: string, column: string): Promise<Column>;
|
||||
async columnInfo(table?: string, column?: string): Promise<Column | Column[]> {
|
||||
// Call the parent columnInfo()
|
||||
// @ts-ignore
|
||||
const columns = await super.columnInfo(table, column);
|
||||
if (!columns?.length) {
|
||||
return columns;
|
||||
}
|
||||
try {
|
||||
await this.knex.raw('select postgis_version()');
|
||||
} catch (error) {
|
||||
return columns;
|
||||
}
|
||||
|
||||
const query = this.knex
|
||||
.with(
|
||||
'geometries',
|
||||
this.knex.raw(`
|
||||
select * from geometry_columns
|
||||
union
|
||||
select * from geography_columns
|
||||
`)
|
||||
)
|
||||
.select<Column[]>({
|
||||
table: 'f_table_name',
|
||||
name: 'f_geometry_column',
|
||||
data_type: 'type',
|
||||
})
|
||||
.from('geometries')
|
||||
.whereIn('f_table_schema', this.explodedSchema);
|
||||
|
||||
if (table) {
|
||||
query.andWhere('f_table_name', table);
|
||||
}
|
||||
if (column) {
|
||||
if (['point', 'polygon'].includes(columns.data_type)) {
|
||||
columns.data_type = 'unknown';
|
||||
}
|
||||
const geometry = await query.andWhere('f_geometry_column', column).first();
|
||||
if (geometry) {
|
||||
columns.data_type = geometry.data_type;
|
||||
}
|
||||
}
|
||||
const geometries = await query;
|
||||
for (const column of columns) {
|
||||
if (['point', 'polygon'].includes(column.data_type)) {
|
||||
column.data_type = 'unknown';
|
||||
}
|
||||
const geometry = geometries.find((geometry) => {
|
||||
return column.name == geometry.name && column.table == geometry.table;
|
||||
});
|
||||
if (geometry) {
|
||||
column.data_type = geometry.data_type;
|
||||
}
|
||||
}
|
||||
return columns;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
"vue-router": "4.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"geojson": "^0.5.0",
|
||||
"npm-run-all": "4.1.5",
|
||||
"rimraf": "3.0.2",
|
||||
"typescript": "4.3.5"
|
||||
|
||||
@@ -16,9 +16,21 @@ export const TYPES = [
|
||||
'uuid',
|
||||
'hash',
|
||||
'csv',
|
||||
'geometry',
|
||||
'unknown',
|
||||
] as const;
|
||||
|
||||
export const GEOMETRY_TYPES = [
|
||||
'Point',
|
||||
'LineString',
|
||||
'Polygon',
|
||||
'MultiPoint',
|
||||
'MultiLineString',
|
||||
'MultiPolygon',
|
||||
] as const;
|
||||
|
||||
export const GEOMETRY_FORMATS = ['native', 'geojson', 'wkt', 'lnglat'] as const;
|
||||
|
||||
export const LOCAL_TYPES = [
|
||||
'standard',
|
||||
'file',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { FilterOperator } from './filter';
|
||||
import { DeepPartial } from './misc';
|
||||
import { Column } from 'knex-schema-inspector/dist/types/column';
|
||||
import { LOCAL_TYPES, TYPES } from '../constants';
|
||||
import { LOCAL_TYPES, TYPES, GEOMETRY_TYPES, GEOMETRY_FORMATS } from '../constants';
|
||||
|
||||
type Translations = {
|
||||
language: string;
|
||||
@@ -13,6 +14,10 @@ export type Type = typeof TYPES[number];
|
||||
|
||||
export type LocalType = typeof LOCAL_TYPES[number];
|
||||
|
||||
export type GeometryType = typeof GEOMETRY_TYPES[number] | 'GeometryCollection' | undefined;
|
||||
|
||||
export type GeometryFormat = typeof GEOMETRY_FORMATS[number];
|
||||
|
||||
export type FieldMeta = {
|
||||
id: number;
|
||||
collection: string;
|
||||
@@ -38,7 +43,7 @@ export interface FieldRaw {
|
||||
collection: string;
|
||||
field: string;
|
||||
type: Type;
|
||||
schema: Column | null;
|
||||
schema: (Column & { geometry_type?: string }) | null;
|
||||
meta: FieldMeta | null;
|
||||
}
|
||||
|
||||
@@ -47,6 +52,8 @@ export interface Field extends FieldRaw {
|
||||
children?: Field[] | null;
|
||||
}
|
||||
|
||||
export type RawField = DeepPartial<Field> & { field: string; type: Type };
|
||||
|
||||
export type ValidationError = {
|
||||
code: string;
|
||||
field: string;
|
||||
|
||||
@@ -14,7 +14,11 @@ export type FilterOperator =
|
||||
| 'between'
|
||||
| 'nbetween'
|
||||
| 'empty'
|
||||
| 'nempty';
|
||||
| 'nempty'
|
||||
| 'intersects'
|
||||
| 'nintersects'
|
||||
| 'intersects_bbox'
|
||||
| 'nintersects_bbox';
|
||||
|
||||
export type ClientFilterOperator = FilterOperator | 'starts_with' | 'nstarts_with' | 'ends_with' | 'nends_with';
|
||||
|
||||
@@ -44,6 +48,10 @@ export type FieldFilterOperator = {
|
||||
_nbetween?: (string | number)[];
|
||||
_empty?: boolean;
|
||||
_nempty?: boolean;
|
||||
_intersects?: string;
|
||||
_nintersects?: string;
|
||||
_intersects_bbox?: string;
|
||||
_nintersects_bbox?: string;
|
||||
};
|
||||
|
||||
export type FieldValidationOperator = {
|
||||
|
||||
29
packages/shared/src/types/geometry.ts
Normal file
29
packages/shared/src/types/geometry.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
Point,
|
||||
Polygon,
|
||||
LineString,
|
||||
MultiPoint,
|
||||
MultiPolygon,
|
||||
MultiLineString,
|
||||
GeometryCollection,
|
||||
Geometry,
|
||||
Feature,
|
||||
FeatureCollection,
|
||||
} from 'geojson';
|
||||
import { GeometryType, GeometryFormat } from './fields';
|
||||
|
||||
export type GeometryOptions = {
|
||||
geometryField: string;
|
||||
geometryFormat: GeometryFormat;
|
||||
geometryType?: GeometryType;
|
||||
};
|
||||
|
||||
export type SimpleGeometry = Point | Polygon | LineString;
|
||||
export type MultiGeometry = MultiPoint | MultiPolygon | MultiLineString;
|
||||
|
||||
export type AnyGeometry = Geometry | GeometryCollection;
|
||||
export type AllGeoJSON = Geometry & GeometryCollection & Feature & FeatureCollection;
|
||||
export type GeoJSONParser = (entry: any) => AnyGeometry | undefined;
|
||||
export type GeoJSONSerializer = (entry: AllGeoJSON) => any;
|
||||
|
||||
export type Coordinate = [number, number];
|
||||
@@ -4,6 +4,7 @@ export * from './endpoints';
|
||||
export * from './extensions';
|
||||
export * from './fields';
|
||||
export * from './filter';
|
||||
export * from './geometry';
|
||||
export * from './hooks';
|
||||
export * from './interfaces';
|
||||
export * from './items';
|
||||
|
||||
@@ -40,6 +40,9 @@ export function getFilterOperatorsForType(type: Type): ClientFilterOperator[] {
|
||||
case 'time':
|
||||
return ['eq', 'neq', 'lt', 'lte', 'gt', 'gte', 'between', 'nbetween', 'empty', 'nempty', 'in', 'nin'];
|
||||
|
||||
case 'geometry':
|
||||
return ['eq', 'neq', 'intersects', 'nintersects', 'intersects_bbox', 'nintersects_bbox'];
|
||||
|
||||
default:
|
||||
return [
|
||||
'eq',
|
||||
|
||||
Reference in New Issue
Block a user