diff --git a/umap/static/umap/base.css b/umap/static/umap/base.css index 4894acf7..e49d049c 100644 --- a/umap/static/umap/base.css +++ b/umap/static/umap/base.css @@ -277,6 +277,7 @@ button.flat, width: initial; display: initial; line-height: inherit; + color: var(--text-color); } button.flat:hover, [type="button"].flat:hover, diff --git a/umap/static/umap/css/contextmenu.css b/umap/static/umap/css/contextmenu.css new file mode 100644 index 00000000..da55f1c8 --- /dev/null +++ b/umap/static/umap/css/contextmenu.css @@ -0,0 +1,11 @@ +.umap-contextmenu { + background-color: var(--background-color); + padding: calc(var(--box-padding) / 2) var(--box-padding); + position: absolute; + z-index: var(--zindex-contextmenu); + border-radius: var(--border-radius); + box-shadow: var(--block-shadow); +} +.umap-contextmenu li + li { + margin-top: var(--text-margin); +} diff --git a/umap/static/umap/css/panel.css b/umap/static/umap/css/panel.css index 19c7240a..bd6d0687 100644 --- a/umap/static/umap/css/panel.css +++ b/umap/static/umap/css/panel.css @@ -27,8 +27,7 @@ .panel.full.on { visibility: visible; right: calc(var(--panel-gutter) * 2 + var(--control-size)); -/* left: var(--panel-gutter);*/ - left: calc(var(--panel-gutter) * 3 + var(--control-size) + var(--panel-width)); + left: calc(var(--panel-gutter) * 2 + var(--control-size)); height: initial; max-height: initial; } @@ -79,6 +78,9 @@ right: calc(var(--panel-gutter) * 2 + var(--control-size)); visibility: visible; } + .panel-left-on .panel.full { + left: calc(var(--panel-gutter) * 3 + var(--control-size) + var(--panel-width)); + } } @media all and (orientation:portrait) { .panel { diff --git a/umap/static/umap/css/tableeditor.css b/umap/static/umap/css/tableeditor.css index 90f955dd..bdad44bd 100644 --- a/umap/static/umap/css/tableeditor.css +++ b/umap/static/umap/css/tableeditor.css @@ -8,6 +8,7 @@ border-collapse: collapse; border-bottom: 1px solid black; border-top: 1px solid black; + min-width: 100%; } .umap-table-editor thead { text-align: center; @@ -73,3 +74,14 @@ .umap-table-editor input { border-radius: initial; } +.umap-table-editor th button { + transform: rotate(90deg); + font-size: 1.25rem; + display: inline-block; + vertical-align: middle; + margin-left: 1rem; + font-weight: bold; +} +.umap-table-editor th button:hover { + text-decoration: none; +} diff --git a/umap/static/umap/css/tooltip.css b/umap/static/umap/css/tooltip.css index eed310ab..6af26178 100644 --- a/umap/static/umap/css/tooltip.css +++ b/umap/static/umap/css/tooltip.css @@ -3,13 +3,13 @@ padding: 5px 10px; width: auto; position: absolute; - box-shadow: 0 1px 7px #999999; + box-shadow: var(--block-shadow); display: none; - background-color: rgba(40, 40, 40, 0.8); + background-color: rgba(40, 40, 40, 0.9); color: #eeeeec; font-size: 0.8em; border-radius: 2px; - z-index: calc(var(--zindex-panels) + 1); + z-index: var(--zindex-tooltip); font-weight: normal; max-width: 300px; } diff --git a/umap/static/umap/img/16-white.svg b/umap/static/umap/img/16-white.svg index 7b537b2c..927c7028 100644 --- a/umap/static/umap/img/16-white.svg +++ b/umap/static/umap/img/16-white.svg @@ -188,5 +188,9 @@ + + + + diff --git a/umap/static/umap/img/source/16-white.svg b/umap/static/umap/img/source/16-white.svg index 2737b5de..3d10b375 100644 --- a/umap/static/umap/img/source/16-white.svg +++ b/umap/static/umap/img/source/16-white.svg @@ -16,7 +16,7 @@ - + @@ -210,5 +210,9 @@ + + + + diff --git a/umap/static/umap/js/modules/facets.js b/umap/static/umap/js/modules/facets.js index 6677b5f6..d595bab7 100644 --- a/umap/static/umap/js/modules/facets.js +++ b/umap/static/umap/js/modules/facets.js @@ -12,8 +12,8 @@ export default class Facets { const properties = {} let selected - names.forEach((name) => { - const type = defined[name].type + for (const name of names) { + const type = defined.get(name).type properties[name] = { type: type } selected = this.selected[name] || {} selected.type = type @@ -22,13 +22,13 @@ export default class Facets { selected.choices = selected.choices || [] } this.selected[name] = selected - }) + } this.map.eachBrowsableDataLayer((datalayer) => { datalayer.eachFeature((feature) => { - names.forEach((name) => { + for (const name of names) { let value = feature.properties[name] - const type = defined[name].type + const type = defined.get(name).type const parser = this.getParser(type) value = parser(value) switch (type) { @@ -56,7 +56,7 @@ export default class Facets { properties[name].choices.push(value) } } - }) + } }) }) return properties @@ -73,7 +73,7 @@ export default class Facets { build() { const defined = this.getDefined() - const names = Object.keys(defined) + const names = [...defined.keys()] const facetProperties = this.compute(names, defined) const fields = names.map((name) => { @@ -90,7 +90,7 @@ export default class Facets { handler = 'FacetSearchDateTime' break } - const label = defined[name].label + const label = defined.get(name).label return [ `selected.${name}`, { @@ -107,12 +107,14 @@ export default class Facets { getDefined() { const defaultType = 'checkbox' const allowedTypes = [defaultType, 'radio', 'number', 'date', 'datetime'] + const defined = new Map() + if (!this.map.options.facetKey) return defined return (this.map.options.facetKey || '').split(',').reduce((acc, curr) => { let [name, label, type] = curr.split('|') type = allowedTypes.includes(type) ? type : defaultType - acc[name] = { label: label || name, type: type } + acc.set(name, { label: label || name, type: type }) return acc - }, {}) + }, defined) } getParser(type) { @@ -127,4 +129,32 @@ export default class Facets { return (v) => String(v || '') } } + + dumps(parsed) { + const dumped = [] + for (const [property, { label, type }] of parsed) { + dumped.push([property, label, type].filter(Boolean).join('|')) + } + return dumped.join(',') + } + + has(property) { + return this.getDefined().has(property) + } + + add(property, label, type) { + const defined = this.getDefined() + if (!defined.has(property)) { + defined.set(property, { label, type }) + this.map.options.facetKey = this.dumps(defined) + this.map.isDirty = true + } + } + + remove(property) { + const defined = this.getDefined() + defined.delete(property) + this.map.options.facetKey = this.dumps(defined) + this.map.isDirty = true + } } diff --git a/umap/static/umap/js/modules/tableeditor.js b/umap/static/umap/js/modules/tableeditor.js index df17ad3e..90212c84 100644 --- a/umap/static/umap/js/modules/tableeditor.js +++ b/umap/static/umap/js/modules/tableeditor.js @@ -1,61 +1,104 @@ import { DomEvent, DomUtil } from '../../vendors/leaflet/leaflet-src.esm.js' import { translate } from './i18n.js' +import ContextMenu from './ui/contextmenu.js' +import { WithTemplate, loadTemplate } from './utils.js' -export default class TableEditor { +const TEMPLATE = ` + + + + + + +
+` + +export default class TableEditor extends WithTemplate { constructor(datalayer) { + super() this.datalayer = datalayer - this.table = DomUtil.create('table') - this.thead = DomUtil.create('thead', '', this.table) - this.header = DomUtil.create('tr', '', this.thead) - this.body = DomUtil.create('tbody', '', this.table) + this.map = this.datalayer.map + this.contextmenu = new ContextMenu({ className: 'dark' }) + this.table = this.loadTemplate(TEMPLATE) this.resetProperties() - this.body.addEventListener('dblclick', (event) => { + this.elements.body.addEventListener('dblclick', (event) => { if (event.target.closest('[data-property]')) this.editCell(event.target) }) - this.body.addEventListener('click', (event) => this.setFocus(event.target)) - this.body.addEventListener('keydown', (event) => this.onKeyDown(event)) + this.elements.body.addEventListener('click', (event) => this.setFocus(event.target)) + this.elements.body.addEventListener('keydown', (event) => this.onKeyDown(event)) + this.elements.header.addEventListener('click', (event) => { + const property = event.target.dataset.property + if (property) this.openHeaderMenu(property) + }) + } + + openHeaderMenu(property) { + let filterItem + if (this.map.facets.has(property)) { + filterItem = { + label: translate('Remove filter for this property'), + action: () => { + this.map.facets.remove(property) + this.map.browser.open('filters') + }, + } + } else { + filterItem = { + label: translate('Add filter for this property'), + action: () => { + this.map.facets.add(property) + this.map.browser.open('filters') + }, + } + } + this.contextmenu.open( + [event.clientX, event.clientY], + [ + { + label: translate('Delete this property on all the features'), + action: () => this.deleteProperty(property), + }, + { + label: translate('Rename this property on all the features'), + action: () => this.renameProperty(property), + }, + filterItem, + ] + ) } renderHeaders() { - this.header.innerHTML = '' - for (let i = 0; i < this.properties.length; i++) { - this.renderHeader(this.properties[i]) + this.elements.header.innerHTML = '' + const th = loadTemplate('') + const checkbox = th.firstChild + this.elements.header.appendChild(th) + for (const property of this.properties) { + this.elements.header.appendChild( + loadTemplate( + `${property}` + ) + ) } - const checkbox = this.header.querySelector('input[type=checkbox]') checkbox.addEventListener('change', (event) => { if (checkbox.checked) this.checkAll() else this.checkAll(false) }) } - renderHeader(property) { - const container = DomUtil.create('th', '', this.header) - const title = DomUtil.add('span', '', container, property) - const del = DomUtil.create('i', 'umap-delete', container) - const rename = DomUtil.create('i', 'umap-edit', container) - del.title = translate('Delete this property on all the features') - rename.title = translate('Rename this property on all the features') - DomEvent.on(del, 'click', () => this.deleteProperty(property)) - DomEvent.on(rename, 'click', () => this.renameProperty(property)) - } - renderBody() { - const bounds = this.datalayer.map.getBounds() - const inBbox = this.datalayer.map.browser.options.inBbox + const bounds = this.map.getBounds() + const inBbox = this.map.browser.options.inBbox let html = '' for (const feature of Object.values(this.datalayer._layers)) { if (feature.isFiltered()) continue if (inBbox && !feature.isOnScreen(bounds)) continue - html += `${this.properties.map((prop) => `${feature.properties[prop] || ''}`).join('')}` + const tds = this.properties.map( + (prop) => + `${feature.properties[prop] || ''}` + ) + html += `${tds.join('')}` } - // this.datalayer.eachLayer(this.renderRow, this) - // const builder = new U.FormBuilder(feature, this.field_properties, { - // id: `umap-feature-properties_${L.stamp(feature)}`, - // className: 'trow', - // callback: feature.resetTooltip, - // }) - // this.body.appendChild(builder.build()) - this.body.innerHTML = html + this.elements.body.innerHTML = html } compileProperties() { @@ -87,7 +130,7 @@ export default class TableEditor { } renameProperty(property) { - this.datalayer.map.dialog + this.map.dialog .prompt(translate('Please enter the new name of this property')) .then(({ prompt }) => { if (!prompt || !this.validateName(prompt)) return @@ -101,7 +144,7 @@ export default class TableEditor { } deleteProperty(property) { - this.datalayer.map.dialog + this.map.dialog .confirm( translate('Are you sure you want to delete this property on all the features?') ) @@ -116,7 +159,7 @@ export default class TableEditor { } addProperty() { - this.datalayer.map.dialog + this.map.dialog .prompt(translate('Please enter the name of the property')) .then(({ prompt }) => { if (!prompt || !this.validateName(prompt)) return @@ -129,29 +172,31 @@ export default class TableEditor { const id = 'tableeditor:edit' this.compileProperties() this.renderHeaders() - this.body.innerHTML = '' + this.elements.body.innerHTML = '' this.renderBody() - const addButton = DomUtil.createButton( - 'flat', - undefined, - translate('Add a new property') - ) - const iconElement = DomUtil.createIcon(addButton, 'icon-add') - addButton.insertBefore(iconElement, addButton.firstChild) - DomEvent.on(addButton, 'click', this.addProperty, this) - const template = document.createElement('template') - template.innerHTML = ` + const addButton = loadTemplate(` + `) + addButton.addEventListener('click', () => this.addProperty()) + + const deleteButton = loadTemplate(` ` - const deleteButton = template.content.firstElementChild + `) deleteButton.addEventListener('click', () => this.deleteRows()) - this.datalayer.map.fullPanel.open({ + const filterButton = loadTemplate(` + `) + filterButton.addEventListener('click', () => this.map.browser.open('filters')) + + this.map.fullPanel.open({ content: this.table, className: 'umap-table-editor', - actions: [addButton, deleteButton], + actions: [addButton, deleteButton, filterButton], }) } @@ -188,19 +233,21 @@ export default class TableEditor { } checkAll(status = true) { - for (const checkbox of this.body.querySelectorAll('input[type=checkbox]')) { + for (const checkbox of this.elements.body.querySelectorAll( + 'input[type=checkbox]' + )) { checkbox.checked = status } } getSelectedRows() { - return Array.from(this.body.querySelectorAll('input[type=checkbox]:checked')).map( - (checkbox) => checkbox.parentNode.parentNode - ) + return Array.from( + this.elements.body.querySelectorAll('input[type=checkbox]:checked') + ).map((checkbox) => checkbox.parentNode.parentNode) } getFocus() { - return this.body.querySelector(':focus') + return this.elements.body.querySelector(':focus') } setFocus(cell) { @@ -210,7 +257,7 @@ export default class TableEditor { deleteRows() { const selectedRows = this.getSelectedRows() if (!selectedRows.length) return - this.datalayer.map.dialog + this.map.dialog .confirm( translate('Found {count} rows. Are you sure you want to delete all?', { count: selectedRows.length, @@ -226,7 +273,8 @@ export default class TableEditor { this.datalayer.show() this.datalayer.fire('datachanged') this.renderBody() - this.datalayer.map.browser.resetFilters() + this.map.browser.resetFilters() + this.map.browser.open('filters') }) } } diff --git a/umap/static/umap/js/modules/ui/base.js b/umap/static/umap/js/modules/ui/base.js new file mode 100644 index 00000000..4f0c176c --- /dev/null +++ b/umap/static/umap/js/modules/ui/base.js @@ -0,0 +1,77 @@ +export class Positioned { + openAt({ anchor, position }) { + if (anchor && position === 'top') { + this.anchorTop(anchor) + } else if (anchor && position === 'left') { + this.anchorLeft(anchor) + } else if (anchor && position === 'bottom') { + this.anchorBottom(anchor) + } else { + this.anchorAbsolute() + } + } + + anchorAbsolute() { + this.container.className = '' + const left = + this.parent.offsetLeft + + this.parent.clientWidth / 2 - + this.container.clientWidth / 2 + const top = this.parent.offsetTop + 75 + this.setPosition({ top: top, left: left }) + } + + anchorTop(el) { + this.container.className = 'tooltip-top' + const coords = this.getPosition(el) + this.setPosition({ + left: coords.left - 10, + bottom: this.getDocHeight() - coords.top + 11, + }) + } + + anchorBottom(el) { + this.container.className = 'tooltip-bottom' + const coords = this.getPosition(el) + this.setPosition({ + left: coords.left, + top: coords.bottom + 11, + }) + } + + anchorLeft(el) { + this.container.className = 'tooltip-left' + const coords = this.getPosition(el) + this.setPosition({ + top: coords.top, + right: document.documentElement.offsetWidth - coords.left + 11, + }) + } + + getPosition(el) { + return el.getBoundingClientRect() + } + + setPosition(coords) { + if (coords.left) this.container.style.left = `${coords.left}px` + else this.container.style.left = 'initial' + if (coords.right) this.container.style.right = `${coords.right}px` + else this.container.style.right = 'initial' + if (coords.top) this.container.style.top = `${coords.top}px` + else this.container.style.top = 'initial' + if (coords.bottom) this.container.style.bottom = `${coords.bottom}px` + else this.container.style.bottom = 'initial' + } + + getDocHeight() { + const D = document + return Math.max( + D.body.scrollHeight, + D.documentElement.scrollHeight, + D.body.offsetHeight, + D.documentElement.offsetHeight, + D.body.clientHeight, + D.documentElement.clientHeight + ) + } +} diff --git a/umap/static/umap/js/modules/ui/contextmenu.js b/umap/static/umap/js/modules/ui/contextmenu.js new file mode 100644 index 00000000..d47a0249 --- /dev/null +++ b/umap/static/umap/js/modules/ui/contextmenu.js @@ -0,0 +1,43 @@ +import { loadTemplate } from '../utils.js' + +export default class ContextMenu { + constructor(options = {}) { + this.options = options + this.container = document.createElement('ul') + this.container.className = 'umap-contextmenu' + if (options.className) { + this.container.classList.add(options.className) + } + this.container.addEventListener('focusout', (event) => { + if (!this.container.contains(event.relatedTarget)) this.close() + }) + } + + open([x, y], items) { + this.container.innerHTML = '' + for (const item of items) { + const li = loadTemplate( + `
  • ` + ) + li.addEventListener('click', () => { + this.close() + item.action() + }) + this.container.appendChild(li) + } + document.body.appendChild(this.container) + this.container.style.top = `${y}px` + this.container.style.left = `${x}px` + this.container.querySelector('button').focus() + this.container.addEventListener('keydown', (event) => { + if (event.key === 'Escape') { + event.stopPropagation() + this.close() + } + }) + } + + close() { + this.container.parentNode.removeChild(this.container) + } +} diff --git a/umap/static/umap/js/modules/ui/panel.js b/umap/static/umap/js/modules/ui/panel.js index f84a92b3..6fa77e60 100644 --- a/umap/static/umap/js/modules/ui/panel.js +++ b/umap/static/umap/js/modules/ui/panel.js @@ -9,7 +9,7 @@ export class Panel { // This will be set once according to the panel configurated at load // or by using panels as popups this.mode = null - this.classname = 'left' + this.className = 'left' DomEvent.disableClickPropagation(this.container) DomEvent.on(this.container, 'contextmenu', DomEvent.stopPropagation) // Do not activate our custom context menu. DomEvent.on(this.container, 'wheel', DomEvent.stopPropagation) @@ -25,9 +25,10 @@ export class Panel { } open({ content, className, actions = [] } = {}) { - this.container.className = `with-transition panel window ${this.classname} ${ + this.container.className = `with-transition panel window ${this.className} ${ this.mode || '' }` + document.body.classList.add(`panel-${this.className.split(' ')[0]}-on`) this.container.innerHTML = '' const actionsContainer = DomUtil.create('ul', 'buttons', this.container) const body = DomUtil.create('div', 'body', this.container) @@ -69,6 +70,7 @@ export class Panel { } close() { + document.body.classList.remove(`panel-${this.className.split(' ')[0]}-on`) if (DomUtil.hasClass(this.container, 'on')) { DomUtil.removeClass(this.container, 'on') this.map.invalidateSize({ pan: false }) @@ -80,14 +82,14 @@ export class Panel { export class EditPanel extends Panel { constructor(map) { super(map) - this.classname = 'right dark' + this.className = 'right dark' } } export class FullPanel extends Panel { constructor(map) { super(map) - this.classname = 'full dark' + this.className = 'full dark' this.mode = 'expanded' } } diff --git a/umap/static/umap/js/modules/ui/tooltip.js b/umap/static/umap/js/modules/ui/tooltip.js index feed1b5e..88a859f9 100644 --- a/umap/static/umap/js/modules/ui/tooltip.js +++ b/umap/static/umap/js/modules/ui/tooltip.js @@ -1,8 +1,10 @@ import { DomEvent, DomUtil } from '../../../vendors/leaflet/leaflet-src.esm.js' import { translate } from '../i18n.js' +import { Positioned } from './base.js' -export default class Tooltip { +export default class Tooltip extends Positioned { constructor(parent) { + super() this.parent = parent this.container = DomUtil.create('div', 'with-transition', this.parent) this.container.id = 'umap-tooltip-container' @@ -13,16 +15,8 @@ export default class Tooltip { } open(opts) { - function showIt() { - if (opts.anchor && opts.position === 'top') { - this.anchorTop(opts.anchor) - } else if (opts.anchor && opts.position === 'left') { - this.anchorLeft(opts.anchor) - } else if (opts.anchor && opts.position === 'bottom') { - this.anchorBottom(opts.anchor) - } else { - this.anchorAbsolute() - } + const showIt = () => { + this.openAt(opts) L.DomUtil.addClass(this.parent, 'umap-tooltip') this.container.innerHTML = U.Utils.escapeHTML(opts.content) } @@ -39,43 +33,6 @@ export default class Tooltip { } } - anchorAbsolute() { - this.container.className = '' - const left = - this.parent.offsetLeft + - this.parent.clientWidth / 2 - - this.container.clientWidth / 2 - const top = this.parent.offsetTop + 75 - this.setPosition({ top: top, left: left }) - } - - anchorTop(el) { - this.container.className = 'tooltip-top' - const coords = this.getPosition(el) - this.setPosition({ - left: coords.left - 10, - bottom: this.getDocHeight() - coords.top + 11, - }) - } - - anchorBottom(el) { - this.container.className = 'tooltip-bottom' - const coords = this.getPosition(el) - this.setPosition({ - left: coords.left, - top: coords.bottom + 11, - }) - } - - anchorLeft(el) { - this.container.className = 'tooltip-left' - const coords = this.getPosition(el) - this.setPosition({ - top: coords.top, - right: document.documentElement.offsetWidth - coords.left + 11, - }) - } - close(id) { // Clear timetout even if a new tooltip has been added // in the meantime. Eg. after a mouseout from the anchor. @@ -86,31 +43,4 @@ export default class Tooltip { this.setPosition({}) L.DomUtil.removeClass(this.parent, 'umap-tooltip') } - - getPosition(el) { - return el.getBoundingClientRect() - } - - setPosition(coords) { - if (coords.left) this.container.style.left = `${coords.left}px` - else this.container.style.left = 'initial' - if (coords.right) this.container.style.right = `${coords.right}px` - else this.container.style.right = 'initial' - if (coords.top) this.container.style.top = `${coords.top}px` - else this.container.style.top = 'initial' - if (coords.bottom) this.container.style.bottom = `${coords.bottom}px` - else this.container.style.bottom = 'initial' - } - - getDocHeight() { - const D = document - return Math.max( - D.body.scrollHeight, - D.documentElement.scrollHeight, - D.body.offsetHeight, - D.documentElement.offsetHeight, - D.body.clientHeight, - D.documentElement.clientHeight - ) - } } diff --git a/umap/static/umap/js/modules/utils.js b/umap/static/umap/js/modules/utils.js index c1427fd0..9b7b11f1 100644 --- a/umap/static/umap/js/modules/utils.js +++ b/umap/static/umap/js/modules/utils.js @@ -378,11 +378,18 @@ export function toggleBadge(element, value) { else delete element.dataset.badge } +export function loadTemplate(html) { + const template = document.createElement('template') + template.innerHTML = html + .split('\n') + .map((line) => line.trim()) + .join('') + return template.content.firstElementChild +} + export class WithTemplate { loadTemplate(html) { - const template = document.createElement('template') - template.innerHTML = html.split('\n').map((line) => line.trim()).join('') - this.element = template.content.firstElementChild + this.element = loadTemplate(html) this.elements = {} for (const element of this.element.querySelectorAll('[data-ref]')) { this.elements[element.dataset.ref] = element diff --git a/umap/static/umap/vars.css b/umap/static/umap/vars.css index 9e6a88b0..b8d3dc25 100644 --- a/umap/static/umap/vars.css +++ b/umap/static/umap/vars.css @@ -44,9 +44,13 @@ --zindex-toolbar: 480; --zindex-autocomplete: 470; --zindex-dialog: 460; + --zindex-contextmenu: 455; --zindex-icon-active: 450; + --zindex-tooltip: 445; --zindex-panels: 440; --zindex-dragover: 410; + + --block-shadow: 0 1px 7px var(--color-mediumGray); } .dark { --background-color: var(--color-darkGray); diff --git a/umap/templates/umap/css.html b/umap/templates/umap/css.html index 92307fca..f488b93e 100644 --- a/umap/templates/umap/css.html +++ b/umap/templates/umap/css.html @@ -29,6 +29,7 @@ +