3
0

Add geometry rendering improvements

This commit is contained in:
Kelani Tolulope
2022-12-14 16:10:14 +01:00
parent 82981edb2b
commit f29b9a1dfe
21 changed files with 1039 additions and 2 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

View File

@@ -14,3 +14,4 @@ export const Comment = require('./Comment.png')
export const Report = require('./Report.png')
export const Progress = require('./Progress.png')
export const Nylas = require('./Nylas.jpg')
export const Geometry = require('./Geometry.png')

View File

@@ -144,6 +144,11 @@ export default {
block: new compose.PageBlockNylas(),
image: images.Nylas,
},
{
label: this.$t('geometry.label'),
block: new compose.PageBlockGeometry(),
image: images.Geometry,
},
],
}
},

View File

@@ -0,0 +1,233 @@
<template>
<wrap
v-bind="$props"
v-on="$listeners"
>
<div
v-if="processing"
class="d-flex align-items-center justify-content-center h-100"
>
<b-spinner />
</div>
<template v-else>
<div
class="w-100 h-100"
@mouseover="disableMap"
@mouseleave="enableMap"
>
<l-map
v-if="map"
ref="map"
:zoom="map.zoom"
:center="map.center"
:min-zoom="map.zoomMin"
:max-zoom="map.zoomMax"
:bounds="map.bounds"
:max-bounds="map.bounds"
class="w-100 h-100"
>
<l-tile-layer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
:attribution="map.attribution"
/>
<l-polygon
v-for="(geometry, i) in geometries"
:key="`polygon-${i}`"
:lat-lngs="geometry.map(value => value.geometry)"
:color="colors[i]"
/>
<l-marker
v-for="(marker, i) in localValue"
:key="`marker-${i}`"
:lat-lng="marker.value"
:icon="getIcon(marker)"
/>
</l-map>
</div>
</template>
</wrap>
</template>
<script>
import { divIcon, latLng, latLngBounds } from 'leaflet'
import {
LPolygon,
} from 'vue2-leaflet'
import { compose, NoID } from '@cortezaproject/corteza-js'
import { mapGetters, mapActions } from 'vuex'
import { evaluatePrefilter } from 'corteza-webapp-compose/src/lib/record-filter'
import base from './base'
export default {
components: { LPolygon },
extends: base,
data () {
return {
map: undefined,
processing: false,
show: false,
geometries: [],
colors: [],
markers: [],
}
},
computed: {
...mapGetters({
getModuleByID: 'module/getByID',
}),
localValue () {
const values = []
this.geometries.forEach((geo) => {
geo.forEach((value) => {
if (value.displayMarker) {
value.markers.map(subValue => {
values.push({ value: this.getLatLng(subValue), color: value.color })
})
}
})
})
return values
},
},
watch: {
'record.recordID': {
immediate: true,
handler () {
this.loadEvents()
},
},
options: {
deep: true,
handler () {
this.loadEvents()
},
},
boundingRect () {
this.loadEvents()
},
},
created () {
this.bounds = this.options.bounds
},
methods: {
...mapActions({
findModuleByID: 'module/findByID',
}),
loadEvents () {
this.geometries = []
this.processing = true
this.colors = this.options.feeds.map(feed => feed.options.color)
const {
bounds,
center,
zoomStarting,
zoomMin,
zoomMax,
} = this.options
this.map = {
bounds: this.options.bounds ? latLngBounds(bounds) : null,
center,
zoom: zoomStarting,
zoomMin,
zoomMax,
}
Promise.all(this.options.feeds.map((feed, idx) => {
return this.findModuleByID({ namespace: this.namespace, moduleID: feed.options.moduleID })
.then(module => {
// Interpolate prefilter variables
if (feed.options.prefilter) {
feed.options.prefilter = evaluatePrefilter(feed.options.prefilter, {
record: this.record,
recordID: (this.record || {}).recordID || NoID,
ownerID: (this.record || {}).ownedBy || NoID,
userID: (this.$auth.user || {}).userID || NoID,
})
}
return compose.PageBlockGeometry.RecordFeed(this.$ComposeAPI, module, this.namespace, feed)
.then(records => {
const mapModuleField = module.fields.find(f => f.name === feed.geometryField)
if (mapModuleField) {
this.geometries[idx] = records.map(e => {
let geometry = e.values[feed.geometryField]
let markers = []
if (mapModuleField.isMulti) {
geometry = geometry.map(value => this.parseGeometryField(value))
markers = geometry
} else {
geometry = this.parseGeometryField(geometry)
markers = [geometry]
}
return ({
title: e.values[feed.titleField],
geometry: feed.displayPolygon ? geometry : [],
markers,
color: feed.options.color,
displayMarker: feed.displayMarker,
})
})
}
})
})
})).finally(() => {
this.processing = false
})
},
getIcon (item) {
item.circleColor = '#ffffff'
return divIcon({
className: 'marker-pin',
html: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 34.892337" height="60" width="40" style="margin-top: -40px;margin-left: -15px;height: 35px;">
<g transform="translate(-814.59595,-274.38623)">
<g transform="matrix(1.1855854,0,0,1.1855854,-151.17715,-57.3976)">
<path d="m 817.11249,282.97118 c -1.25816,1.34277 -2.04623,3.29881 -2.01563,5.13867 0.0639,3.84476 1.79693,5.3002 4.56836,10.59179 0.99832,2.32851 2.04027,4.79237 3.03125,8.87305 0.13772,0.60193 0.27203,1.16104 0.33416,1.20948 0.0621,0.0485 0.19644,-0.51262 0.33416,-1.11455 0.99098,-4.08068 2.03293,-6.54258 3.03125,-8.87109 2.77143,-5.29159 4.50444,-6.74704 4.56836,-10.5918 0.0306,-1.83986 -0.75942,-3.79785 -2.01758,-5.14062 -1.43724,-1.53389 -3.60504,-2.66908 -5.91619,-2.71655 -2.31115,-0.0475 -4.4809,1.08773 -5.91814,2.62162 z" style="fill:${item.color};stroke:${item.color};"/>
<circle r="3.0355" cy="288.25278" cx="823.03064" id="path3049" style="display:inline;fill:${item.circleColor};"/>
</g>
</g>
</svg>`,
})
},
parseGeometryField (value) {
return JSON.parse(value || '{"coordinates":[]}').coordinates || []
},
getLatLng (coordinates = [undefined, undefined]) {
const [lat, lng] = coordinates
if (lat && lng) {
return latLng(lat, lng)
}
},
disableMap () {
if (this.editable) this.$refs.map.mapObject._handlers.forEach(handler => handler.disable())
},
enableMap () {
if (this.editable) this.$refs.map.mapObject._handlers.forEach(handler => handler.enable())
},
},
}
</script>

View File

@@ -0,0 +1,199 @@
<template>
<div>
<div class="my-2">
<l-map
ref="map"
:zoom="options.zoomStarting"
:min-zoom="options.zoomMin"
:max-zoom="options.zoomMax"
:center="options.center"
:bounds="bounds"
:max-bounds="options.bounds"
class="w-100 cursor-pointer"
style="height: 45vh;"
@update:zoom="zoomUpdated"
@update:center="updateCenter"
@update:bounds="boundsUpdated"
>
<l-tile-layer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
:attribution="map.attribution"
/>
</l-map>
<b-form-text id="password-help-block">
{{ $t('geometry.mapHelpText') }}
</b-form-text>
</div>
<hr>
<b-row
class="mb-2 mt-4"
>
<b-col
sm="12"
md="4"
>
<b-form-group
:label="$t('geometry.zoom.zoomStartingLabel')"
class="rounded-left"
>
<b-form-input
v-model="options.zoomStarting"
number
readonly
type="number"
/>
</b-form-group>
</b-col>
<b-col
sm="12"
md="4"
>
<b-form-group
:label="$t('geometry.zoom.zoomMinLabel')"
:description="`${options.zoomMin}`"
class="rounded-0"
>
<b-form-input
v-model="options.zoomMin"
number
:min="1"
:max="18"
type="range"
/>
</b-form-group>
</b-col>
<b-col
sm="12"
md="4"
>
<b-form-group
:label="$t('geometry.zoom.zoomMaxLabel')"
:description="`${options.zoomMax}`"
>
<b-form-input
v-model="options.zoomMax"
number
:min="1"
:max="18"
type="range"
/>
</b-form-group>
</b-col>
<b-col
sm="12"
md="4"
>
<b-form-group
label-class="text-primary"
:label="$t('geometry.centerLabel')"
>
<b-input-group>
<b-form-input
v-model="options.center[0]"
type="number"
number
:placeholder="$t('latitude')"
/>
<b-form-input
v-model="options.center[1]"
type="number"
number
:placeholder="$t('longitude')"
/>
</b-input-group>
</b-form-group>
</b-col>
<b-col
sm="12"
md="4"
>
<b-form-group
:label="$t('geometry.bounds.lockBounds')"
class="rounded-left"
>
<b-form-checkbox
v-model="options.lockBounds"
name="lock-bounds"
switch
size="lg"
@change="updateBounds"
/>
</b-form-group>
</b-col>
</b-row>
</div>
</template>
<script>
import { latLng } from 'leaflet'
import base from '../base'
export default {
components: {},
i18nOptions: {
namespaces: 'block',
},
extends: base,
data () {
return {
map: {
show: false,
zoom: 3,
center: [30, 30],
rotation: 0,
attribution: '&copy; <a target="_blank" href="http://osm.org/copyright">OpenStreetMap</a>',
},
localValue: { coordinates: [] },
center: [],
bounds: null,
}
},
methods: {
getLatLng (coordinates = [undefined, undefined]) {
const [lat, lng] = coordinates
if (lat && lng) {
return latLng(lat, lng)
}
},
updateCenter (coordinates) {
let { lat = 0, lng = 0 } = coordinates || {}
lat = Math.round(lat * 1e7) / 1e7
lng = Math.round(lng * 1e7) / 1e7
this.options.center = [lat, lng]
},
boundsUpdated (coordinates) {
this.bounds = coordinates
this.updateBounds(this.options.lockBounds)
},
zoomUpdated (zoom) {
this.options.zoomStarting = zoom
},
updateBounds (value) {
if (value) {
const bounds = this.bounds || this.$refs.map.mapObject.getBounds()
const { _northEast, _southWest } = bounds
this.options.bounds = [Object.values(_northEast), Object.values(_southWest)]
} else {
this.options.bounds = null
}
},
},
}
</script>
<style></style>

View File

@@ -0,0 +1,209 @@
<template>
<div>
<template v-if="feed.options">
<b-form-group
horizontal
:label-cols="3"
breakpoint="md"
:label="$t('geometry.recordFeed.moduleLabel')"
>
<b-input-group>
<b-form-select
v-model="feed.options.moduleID"
:options="modules"
value-field="moduleID"
text-field="name"
>
<template slot="first">
<option value="0">
{{ $t('geometry.recordFeed.modulePlaceholder') }}
</option>
</template>
</b-form-select>
</b-input-group>
</b-form-group>
<template v-if="module">
<b-form-group
horizontal
:label-cols="3"
breakpoint="md"
:label="$t('geometry.recordFeed.colorLabel')"
>
<b-input-group>
<b-form-input
v-model="feed.options.color"
style="max-width: 50px;"
type="color"
debounce="300"
/>
</b-input-group>
</b-form-group>
<b-form-group
horizontal
:label-cols="3"
breakpoint="md"
:label="$t('geometry.recordFeed.titleLabel')"
>
<b-form-select
v-model="feed.titleField"
:options="titleFields | optionizeFields"
>
<template slot="first">
<option
disabled
value=""
>
{{ $t('geometry.recordFeed.titlePlaceholder') }}
</option>
</template>
</b-form-select>
</b-form-group>
<b-form-group
horizontal
:label-cols="3"
breakpoint="md"
:label="$t('geometry.recordFeed.displayMarker')"
>
<b-form-checkbox
v-model="feed.displayMarker"
name="display-marker"
switch
size="lg"
/>
</b-form-group>
<b-form-group
horizontal
:label-cols="3"
breakpoint="md"
:label="$t('geometry.recordFeed.displayPolygon')"
>
<b-form-checkbox
v-model="feed.displayPolygon"
name="display-marker"
switch
size="lg"
/>
</b-form-group>
<b-form-group
horizontal
:label-cols="3"
breakpoint="md"
:label="$t('geometry.recordFeed.geometryFieldLabel')"
>
<b-form-select
v-model="feed.geometryField"
:options="geometryFields | optionizeFields"
>
<template slot="first">
<option
disabled
value=""
>
{{ $t('geometry.recordFeed.geometryFieldPlaceholder') }}
</option>
</template>
</b-form-select>
</b-form-group>
<br>
<b-form-group
horizontal
:label-cols="3"
breakpoint="md"
:label="$t('calendar.recordFeed.prefilterLabel')"
>
<b-form-textarea
v-model="feed.options.prefilter"
:value="true"
:placeholder="$t('calendar.recordFeed.prefilterPlaceholder')"
/>
</b-form-group>
</template>
</template>
</div>
</template>
<script>
import base from './base'
export default {
i18nOptions: {
namespaces: 'block',
},
extends: base,
computed: {
/**
* Finds the module, this feed configurator should use
* @returns {Module|undefined}
*/
module () {
if (!(this.feed.options || {}).moduleID) {
return
}
return this.modules.find(({ moduleID }) => moduleID === this.feed.options.moduleID)
},
/**
* Determines if given module has any multi-fields
* @returns {Boolean}
*/
hasMultiFields () {
if (!this.module) {
return false
}
return this.module.fields.reduce((acc, { isMulti }) => acc || isMulti, false)
},
/**
* Determines available title fields based on the given module.
* @returns {Array}
*/
titleFields () {
if (!this.module) {
return []
}
return [...this.module.fields]
.filter(f => [
'DateTime',
'Select',
'Number',
'Bool',
'String',
'Record',
'User',
].includes(f.kind))
.sort((a, b) => a.label.localeCompare(b.label))
},
/**
* Determines available geometry fields based on the given module.
* Currently ignores multi-fields
* @returns {Array}
*/
geometryFields () {
if (!this.module) {
return []
}
const moduleFields = this.module.fields.slice().sort((a, b) => a.label.localeCompare(b.label))
return [
...moduleFields,
...this.module.systemFields().map(sf => {
sf.label = this.$t(`field:system.${sf.name}`)
return sf
}),
].filter(f => f.kind === 'Geometry')
},
},
}
</script>

View File

@@ -0,0 +1,36 @@
<script>
/**
* Provides base for each feed configurator.
* Define common props, methods, ... in here.
*/
export default {
filters: {
/**
* Prepares a set of select options
* @param {Array} ff Raw options
* @returns {Array}
*/
optionizeFields (ff) {
return ff.map(f => {
return {
text: f.label || f.name,
value: f.name,
}
})
},
},
props: {
feed: {
type: Object,
required: true,
default: () => ({}),
},
modules: {
type: Array,
required: false,
default: () => [],
},
},
}
</script>

View File

@@ -0,0 +1 @@
export { default as record } from './Record'

View File

@@ -0,0 +1,117 @@
<template>
<fieldset class="form-group">
<div
v-for="(feed, i) in options.feeds"
:key="i"
>
<div
class="d-flex justify-content-between mb-3"
>
<h5>
{{ $t('geometry.feedLabel') }}
</h5>
<template
v-if="feed.resource"
>
<b-button
variant="outline-danger"
class="border-0"
@click="onRemoveFeed(i)"
>
<font-awesome-icon :icon="['far', 'trash-alt']" />
</b-button>
</template>
</div>
<b-form-group horizontal>
<component
:is="configurator(feed)"
v-if="feed.resource && configurator(feed)"
:feed="feed"
:modules="modules"
/>
</b-form-group>
<hr>
</div>
<b-button
class="btn btn-url test-feed-add"
@click.prevent="handleAddButton"
>
{{ $t('geometry.addSource') }}
</b-button>
</fieldset>
</template>
<script>
import { mapGetters } from 'vuex'
import base from '../../base'
import * as configs from './configs'
import { compose } from '@cortezaproject/corteza-js'
export default {
i18nOptions: {
namespaces: 'block',
},
components: {
},
extends: base,
computed: {
...mapGetters({
modules: 'module/set',
}),
/**
* Provides a set of available feed sources.
* @returns {Array}
*/
feedSources () {
return Object.entries(compose.PageBlockGeometry.feedResources).map(([key, value]) => ({
value,
text: this.$t(`geometry.${key}Feed.optionLabel`),
}))
},
},
created () {
if (this.options.feeds.length === 0) {
this.block.options.feeds = []
}
},
methods: {
/**
* Handles feed removal
* @param {Number} i Feed's index
*/
onRemoveFeed (i) {
this.block.options.feeds.splice(i, 1)
},
/**
* Handles feed's addition
*/
handleAddButton () {
this.block.options.feeds.push(compose.PageBlockGeometry.makeFeed())
},
/**
* configurator uses feed's resource to determine what configurator to use.
* If it can't find an apropriate component, undefined is returned
* @param {Feed} feed Feed in qestion
* @returns {Component|undefined}
*/
configurator (feed) {
if (!feed.resource) {
return
}
const r = feed.resource.split(':').pop()
return configs[r]
},
},
}
</script>

View File

@@ -0,0 +1,35 @@
<template>
<div>
<b-tab :title="$t('geometry.viewLabel')">
<configurator
v-bind="$attrs"
v-on="$listeners"
/>
</b-tab>
<b-tab :title="$t('geometry.feedLabel')">
<feed-source
v-bind="$attrs"
v-on="$listeners"
/>
</b-tab>
</div>
</template>
<script>
import FeedSource from './FeedSource'
import Configurator from './Configurator'
export default {
i18nOptions: {
namespaces: 'block',
},
components: {
FeedSource,
Configurator,
},
inheritAttrs: false,
}
</script>

View File

@@ -55,6 +55,12 @@ export default {
required: false,
default: '',
},
editable: {
type: Boolean,
required: false,
default: false,
},
},
data () {

View File

@@ -34,6 +34,8 @@ import ProgressBase from './ProgressBase'
import ProgressConfigurator from './ProgressConfigurator'
import NylasBase from './Nylas/NylasBase'
import NylasConfigurator from './Nylas/NylasConfigurator'
import GeometryBase from './GeometryBase'
import GeometryConfigurator from './GeometryConfigurator/index'
/**
* List of all known page block components
@@ -73,6 +75,8 @@ const Registry = {
ProgressConfigurator,
NylasBase,
NylasConfigurator,
GeometryBase,
GeometryConfigurator,
}
const defaultMode = 'Base'

View File

@@ -100,7 +100,7 @@
</div>
<page-block
v-bind="{ ...$attrs, ...$props, page, block, boundingRect, blockIndex: index }"
v-bind="{ ...$attrs, ...$props, page, block, boundingRect, blockIndex: index, editable: true }"
:record="record"
:module="module"
class="p-2"

View File

@@ -0,0 +1,35 @@
import { Record } from '../../record'
import { Namespace } from '../../namespace'
import { Module } from '../../module'
import { Compose as ComposeAPI } from '../../../../api-clients'
interface FeedOptions {
color: string;
prefilter: string;
}
interface Feed {
titleField: string;
options: FeedOptions;
}
interface Range {
end: Date;
start: Date;
}
export async function RecordFeed ($ComposeAPI: ComposeAPI, module: Module, namespace: Namespace, feed: Feed): Promise<any[]> {
// Params for record fetching
const params = {
namespaceID: namespace.namespaceID,
moduleID: module.moduleID,
query: feed.options.prefilter,
}
const events: Array<any> = []
return $ComposeAPI.recordList(params).then(({ set }) => {
return (set as Array<{ recordID: string }>)
// cast & freeze
.map(r => Object.freeze(new Record(module, r)))
})
}

View File

@@ -0,0 +1,56 @@
import { Apply, NoID } from '../../../../cast'
import { IsOf } from '../../../../guards'
interface FeedOptions {
color: string;
prefilter: string;
moduleID: string;
resource: string;
titleField: string;
geometryField: string;
displayMarker: boolean;
displayPolygon: boolean;
}
export type FeedInput = Partial<Feed> | Feed
const defOptions = {
moduleID: NoID,
color: '#2f85cb',
prefilter: '',
resource: 'compose:record',
titleField: '',
geometryField: '',
displayMarker: false,
displayPolygon: true,
}
/**
* Feed class represents an event feed for the given calendar
*/
export default class Feed {
public resource = 'compose:record'
public titleField = ''
public color = '#2f85cb'
public geometryField = ''
public displayMarker = false
public displayPolygon = true
public options: FeedOptions = { ...defOptions }
constructor (i?: FeedInput) {
this.apply(i)
}
apply (i?: FeedInput): void {
if (!i) return
if (IsOf<Feed>(i, 'resource')) {
Apply(this, i, String, 'resource', 'color', 'titleField', 'geometryField')
Apply(this, i, Boolean, 'displayMarker', 'displayPolygon')
if (i.options) {
this.options = { ...this.options, ...i.options }
}
}
}
}

View File

@@ -0,0 +1 @@
export { PageBlockGeometry } from './page-block'

View File

@@ -0,0 +1,63 @@
import { PageBlock, Registry } from '../base'
import { Apply } from '../../../../cast'
import Feed, { FeedInput } from './feed'
import { RecordFeed } from './feed-record'
const kind = 'Geometry'
type Bounds = number[][]
interface Options {
defaultView: string;
center: Array<number>;
feeds: Array<Feed>;
zoomStarting: number;
zoomMin: number;
zoomMax: number;
bounds: Bounds | null;
lockBounds: boolean;
}
const defaults: Readonly<Options> = Object.freeze({
defaultView: '',
center: [35, -30],
feeds: [],
zoomStarting: 2,
zoomMin: 1,
zoomMax: 18,
bounds: null,
lockBounds: false,
})
export class PageBlockGeometry extends PageBlock {
readonly kind = kind
options: Options = { ...defaults }
static feedResources = Object.freeze({
record: 'compose:record',
})
constructor (i?: PageBlock | Partial<PageBlock>) {
super(i)
this.applyOptions(i?.options as Partial<Options>)
}
applyOptions (o?: Partial<Options>): void {
if (!o) return
this.options.feeds = (o.feeds || []).map(f => new Feed(f))
this.options.center = (o.center || [])
this.options.bounds = (o.bounds || null)
Apply(this.options, o, Number, 'zoomStarting', 'zoomMin', 'zoomMax')
Apply(this.options, o, Boolean, 'lockBounds')
}
static makeFeed (f?: FeedInput): Feed {
return new Feed(f)
}
static RecordFeed = RecordFeed
}
Registry.set(kind, PageBlockGeometry)

View File

@@ -0,0 +1,3 @@
export const feedResources = {
record: 'compose:record',
}

View File

@@ -15,6 +15,8 @@ export { PageBlockComment } from './comment'
export { PageBlockReport } from './report'
export { PageBlockProgress } from './progress'
export { PageBlockNylas } from './nylas'
export { PageBlockGeometry } from './geometry'
export function PageBlockMaker<T extends PageBlock> (i: { kind: string }): T {
const PageBlockTemp = Registry.get(i.kind)

View File

@@ -565,4 +565,34 @@ nylas:
email: Email
mailbox: Mailbox
geometry:
label: Geometry
viewLabel: Geometry
feedLabel: Configure Sources
addSource: Add source
lock: Lock
mapHelpText: Move and scroll the map to the position that will be used as a default. You can also lock the position or zoom to prevent users from moving too far.
recordFeed:
moduleLabel: Select module
modulePlaceholder: (No module)
noMultiFields: Multi-value fields are currently not supported
optionLabel: Records
prefilterLabel: Prefilter sources
colorLabel: Marker color
geometryFieldLabel: Geometry field
titleLabel: Title
titlePlaceholder: Title
geometryFieldPlaceholder: Geometry field
displayMarker: Display marker
displayPolygon: Display polygon
centerLabel: Center
zoom:
zoomLabel: Zoom
zoomMinLabel: Zoom min
zoomMaxLabel: Zoom max
zoomStartingLabel: Zoom Starting
bounds:
boundsLabel: Bounds
lockBounds: Lock bounds
topLeft: Bounds top left
lowerRight: Bounds lower right

View File

@@ -96,6 +96,7 @@ kind:
modulePlaceholder: Pick module
queryFieldsLabel: Query fields on search
moduleField: Module field
variantField: Variant field
fieldFromModuleField: Label field from related module field
pickField: Pick field
suggestionPlaceholder: Start typing to search for records