diff --git a/api/package.json b/api/package.json index 97d033e84d..e71a491966 100644 --- a/api/package.json +++ b/api/package.json @@ -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", diff --git a/api/src/database/helpers/geometry.ts b/api/src/database/helpers/geometry.ts new file mode 100644 index 0000000000..c3dcc3e84a --- /dev/null +++ b/api/src/database/helpers/geometry.ts @@ -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)); + } +} diff --git a/api/src/database/migrations/20210811A-add-geometry-config.ts b/api/src/database/migrations/20210811A-add-geometry-config.ts new file mode 100644 index 0000000000..dc86a4cb8a --- /dev/null +++ b/api/src/database/migrations/20210811A-add-geometry-config.ts @@ -0,0 +1,15 @@ +import { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + await knex.schema.alterTable('directus_settings', (table) => { + table.json('basemaps'); + table.string('mapbox_key'); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.alterTable('directus_settings', (table) => { + table.dropColumn('basemaps'); + table.dropColumn('mapbox_key'); + }); +} diff --git a/api/src/database/run-ast.ts b/api/src/database/run-ast.ts index d7a216c54a..e9302a5d3d 100644 --- a/api/src/database/run-ast.ts +++ b/api/src/database/run-ast.ts @@ -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 { + 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; diff --git a/api/src/database/seeds/run.ts b/api/src/database/seeds/run.ts index cf86c8eddf..6a14b83ff9 100644 --- a/api/src/database/seeds/run.ts +++ b/api/src/database/seeds/run.ts @@ -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 { 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); } diff --git a/api/src/database/system-data/fields/settings.yaml b/api/src/database/system-data/fields/settings.yaml index 560539f32e..f135037b38 100644 --- a/api/src/database/system-data/fields/settings.yaml +++ b/api/src/database/system-data/fields/settings.yaml @@ -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 diff --git a/api/src/services/collections.ts b/api/src/services/collections.ts index 7ce3c213b5..df6a55d5bc 100644 --- a/api/src/services/collections.ts +++ b/api/src/services/collections.ts @@ -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; diff --git a/api/src/services/fields.ts b/api/src/services/fields.ts index f4d5256a6f..6aabdc8482 100644 --- a/api/src/services/fields.ts +++ b/api/src/services/fields.ts @@ -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: 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); } diff --git a/api/src/services/graphql.ts b/api/src/services/graphql.ts index e0a7a721f9..dc20f4d71c 100644 --- a/api/src/services/graphql.ts +++ b/api/src/services/graphql.ts @@ -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; } diff --git a/api/src/services/items.ts b/api/src/services/items.ts index a018353822..ce0b95324f 100644 --- a/api/src/services/items.ts +++ b/api/src/services/items.ts @@ -172,8 +172,8 @@ export class ItemsService 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 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 = ( diff --git a/api/src/services/payload.ts b/api/src/services/payload.ts index ccf80d70fb..becd7288ef 100644 --- a/api/src/services/payload.ts +++ b/api/src/services/payload.ts @@ -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; accountability: Accountability | null; + specials: string[]; }) => Promise; }; @@ -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>[]>(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>[], - action: Action - ): Promise>[]> { + processDates(payloads: Partial>[], action: Action): Partial>[] { 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): Promise { + 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); + } } diff --git a/api/src/services/specifications.ts b/api/src/services/specifications.ts index eb4957b5d1..a3d1be4107 100644 --- a/api/src/services/specifications.ts +++ b/api/src/services/specifications.ts @@ -526,6 +526,9 @@ class OASSpecsService implements SpecificationSubService { type: 'string', format: 'uuid', }, + geometry: { + type: 'string', + }, }; } diff --git a/api/src/types/query.ts b/api/src/types/query.ts index 70af5e1fcc..8937a4aac3 100644 --- a/api/src/types/query.ts +++ b/api/src/types/query.ts @@ -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'; diff --git a/api/src/types/schema.ts b/api/src/types/schema.ts index 0bc23e94a2..c0c5eddf2b 100644 --- a/api/src/types/schema.ts +++ b/api/src/types/schema.ts @@ -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; }; }; }; diff --git a/api/src/utils/apply-query.ts b/api/src/utils/apply-query.ts index 2c66e2e812..d12df2ddcd 100644 --- a/api/src/utils/apply-query.ts +++ b/api/src/utils/apply-query.ts @@ -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) { diff --git a/api/src/utils/geometry.ts b/api/src/utils/geometry.ts new file mode 100644 index 0000000000..3f6ca7956e --- /dev/null +++ b/api/src/utils/geometry.ts @@ -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()); +} diff --git a/api/src/utils/get-default-value.ts b/api/src/utils/get-default-value.ts index afb8d60217..ad33f029fd 100644 --- a/api/src/utils/get-default-value.ts +++ b/api/src/utils/get-default-value.ts @@ -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; diff --git a/api/src/utils/get-graphql-type.ts b/api/src/utils/get-graphql-type.ts index 4edce04d06..e3ec380b98 100644 --- a/api/src/utils/get-graphql-type.ts +++ b/api/src/utils/get-graphql-type.ts @@ -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 { @@ -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': diff --git a/api/src/utils/get-local-type.ts b/api/src/utils/get-local-type.ts index 839596fe93..6650225b47 100644 --- a/api/src/utils/get-local-type.ts +++ b/api/src/utils/get-local-type.ts @@ -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 = { +type LocalTypeEntry = { + type: Type | 'unknown'; + geometry_type?: 'Point' | 'LineString' | 'Polygon' | 'MultiPoint' | 'MultiLineString' | 'MultiPolygon'; +}; + +const localTypeMap: Record = { // Shared boolean: { type: 'boolean' }, tinyint: { type: 'integer' }, @@ -38,6 +43,15 @@ const localTypeMap: Record = { 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 = { 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 = { _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 = { 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' }; } diff --git a/api/src/utils/get-schema.ts b/api/src/utils/get-schema.ts index caf64640de..077e3a4d33 100644 --- a/api/src/utils/get-schema.ts +++ b/api/src/utils/get-schema.ts @@ -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, }; diff --git a/api/src/utils/sanitize-query.ts b/api/src/utils/sanitize-query.ts index e6e87191b3..70f2825ddf 100644 --- a/api/src/utils/sanitize-query.ts +++ b/api/src/utils/sanitize-query.ts @@ -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, 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; diff --git a/api/src/utils/validate-query.ts b/api/src/utils/validate-query.ts index 1b626e7a53..7971ef0bfe 100644 --- a/api/src/utils/validate-query.ts +++ b/api/src/utils/validate-query.ts @@ -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; +} diff --git a/app/package.json b/app/package.json index a3dfdc1b57..bc49f9410d 100644 --- a/app/package.json +++ b/app/package.json @@ -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" } } diff --git a/app/src/components/v-form/form-field-interface.vue b/app/src/components/v-form/form-field-interface.vue index ae5a3ef70f..ebfa35f2c1 100644 --- a/app/src/components/v-form/form-field-interface.vue +++ b/app/src/components/v-form/form-field-interface.vue @@ -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)" diff --git a/app/src/hydrate.ts b/app/src/hydrate.ts index 3b31c3032e..5af4461667 100644 --- a/app/src/hydrate.ts +++ b/app/src/hydrate.ts @@ -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 { await registerModules(); await setLanguage((userStore.currentUser?.language as Language) || 'en-US'); } + + appStore.basemap = getBasemapSources()[0].name; } catch (error) { appStore.error = error; } finally { diff --git a/app/src/interfaces/group-accordion/accordion-section.vue b/app/src/interfaces/group-accordion/accordion-section.vue index 8055edb179..3cd4faf5fe 100644 --- a/app/src/interfaces/group-accordion/accordion-section.vue +++ b/app/src/interfaces/group-accordion/accordion-section.vue @@ -7,7 +7,7 @@ -
+
+
+
+
+ + +
+ + + + {{ geometryOptionsError }} + + + + + {{ geometryParsingError }} + + + + +
+ + + + + + + diff --git a/app/src/interfaces/map/options.vue b/app/src/interfaces/map/options.vue new file mode 100644 index 0000000000..cd14a5ae25 --- /dev/null +++ b/app/src/interfaces/map/options.vue @@ -0,0 +1,132 @@ + + + + + diff --git a/app/src/interfaces/map/style.ts b/app/src/interfaces/map/style.ts new file mode 100644 index 0000000000..6940e6f7af --- /dev/null +++ b/app/src/interfaces/map/style.ts @@ -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, + }, + }, +]; diff --git a/app/src/lang/translations/en-US.yaml b/app/src/lang/translations/en-US.yaml index 8c2fff109c..03e3859565 100644 --- a/app/src/lang/translations/en-US.yaml +++ b/app/src/lang/translations/en-US.yaml @@ -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 diff --git a/app/src/lang/translations/fr-FR.yaml b/app/src/lang/translations/fr-FR.yaml index 42c3a3a5ed..5408b60f5d 100644 --- a/app/src/lang/translations/fr-FR.yaml +++ b/app/src/lang/translations/fr-FR.yaml @@ -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 diff --git a/app/src/layouts/map/actions.vue b/app/src/layouts/map/actions.vue new file mode 100644 index 0000000000..1fde62e009 --- /dev/null +++ b/app/src/layouts/map/actions.vue @@ -0,0 +1,46 @@ + + + + + diff --git a/app/src/layouts/map/components/map.vue b/app/src/layouts/map/components/map.vue new file mode 100644 index 0000000000..80eb817df7 --- /dev/null +++ b/app/src/layouts/map/components/map.vue @@ -0,0 +1,511 @@ + + + + + + + + diff --git a/app/src/layouts/map/index.ts b/app/src/layouts/map/index.ts new file mode 100644 index 0000000000..aa523711d2 --- /dev/null +++ b/app/src/layouts/map/index.ts @@ -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; + geometryFormat?: GeometryFormat; + geometryField?: string; + fitDataToView?: boolean; + clusterData?: boolean; + animateOptions?: any; +}; + +export default defineLayout({ + 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(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({ + 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(() => { + 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(() => { + 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({ type: 'FeatureCollection', features: [] }); + const geojsonBounds = ref(); + const geojsonError = ref(); + 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 | 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(ref: Ref, key: T, defaultValue: R[T]) { + return computed({ + get: () => ref.value?.[key] ?? defaultValue, + set: (value: R[T]) => { + ref.value = Object.assign({}, ref.value, { [key]: value }) as R; + }, + }); + } + }, +}); diff --git a/app/src/layouts/map/map.vue b/app/src/layouts/map/map.vue new file mode 100644 index 0000000000..1a5c82d099 --- /dev/null +++ b/app/src/layouts/map/map.vue @@ -0,0 +1,255 @@ + + + + + + + + + diff --git a/app/src/layouts/map/options.vue b/app/src/layouts/map/options.vue new file mode 100644 index 0000000000..8e20efc692 --- /dev/null +++ b/app/src/layouts/map/options.vue @@ -0,0 +1,110 @@ + + + diff --git a/app/src/layouts/map/sidebar.vue b/app/src/layouts/map/sidebar.vue new file mode 100644 index 0000000000..2199ae9d32 --- /dev/null +++ b/app/src/layouts/map/sidebar.vue @@ -0,0 +1,24 @@ + + + diff --git a/app/src/layouts/map/style.ts b/app/src/layouts/map/style.ts new file mode 100644 index 0000000000..532cad3d31 --- /dev/null +++ b/app/src/layouts/map/style.ts @@ -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], + }, + }, +]; diff --git a/app/src/modules/collections/routes/collection.vue b/app/src/modules/collections/routes/collection.vue index 4762224174..418a5db42d 100644 --- a/app/src/modules/collections/routes/collection.vue +++ b/app/src/modules/collections/routes/collection.vue @@ -1,6 +1,10 @@