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:
Oreille
2021-08-12 22:01:34 +02:00
committed by GitHub
parent 66d4c04121
commit 83e8814b2d
67 changed files with 4982 additions and 231 deletions

View File

@@ -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",

View 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));
}
}

View 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');
});
}

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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 = (

View File

@@ -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);
}
}

View File

@@ -526,6 +526,9 @@ class OASSpecsService implements SpecificationSubService {
type: 'string',
format: 'uuid',
},
geometry: {
type: 'string',
},
};
}

View File

@@ -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';

View File

@@ -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;
};
};
};

View File

@@ -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
View 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());
}

View File

@@ -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;

View File

@@ -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':

View File

@@ -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' };
}

View File

@@ -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,
};

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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"
}
}

View File

@@ -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)"

View File

@@ -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 {

View File

@@ -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"

View File

@@ -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',

View File

@@ -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,
});

View 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: [],
});

View 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>

View 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>

View 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,
},
},
];

View File

@@ -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

View File

@@ -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

View 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>

View 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>

View 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
View 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>

View 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>

View 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>

View 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],
},
},
];

View File

@@ -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() {

View File

@@ -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,

View File

@@ -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,

View File

@@ -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',

View File

@@ -8,5 +8,6 @@ export const useAppStore = defineStore({
hydrating: false,
error: null,
authenticated: false,
basemap: 'OpenStreetMap',
}),
});

View 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;
}

View 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;
}
}

View 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];
}

View File

@@ -19,6 +19,7 @@ const defaultDisplayMap: Record<Type, string> = {
unknown: 'raw',
csv: 'labels',
hash: 'formatted-value',
geometry: 'map',
};
export function getDefaultDisplayForType(type: Type): string {

View File

@@ -19,6 +19,7 @@ const defaultInterfaceMap: Record<Type, string> = {
unknown: 'input',
csv: 'tags',
hash: 'input-hash',
geometry: 'map',
};
export function getDefaultInterfaceForType(type: Type): string {

View 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;
}

View 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';
}

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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>

View File

@@ -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();

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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;
}
}

View File

@@ -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"

View File

@@ -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',

View 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;

View File

@@ -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 = {

View 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];

View File

@@ -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';

View File

@@ -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',