diff options
author | Guilhem Moulin <guilhem@fripost.org> | 2025-06-08 17:28:28 +0200 |
---|---|---|
committer | Guilhem Moulin <guilhem@fripost.org> | 2025-06-11 12:41:44 +0200 |
commit | 78b07067b4aba79fa0b5f282a067856f41e9ab31 (patch) | |
tree | 32e8184ca6d3fca7d766814645470669199fe4d4 | |
parent | e78dc2e27b61fc60c4f378c461196e7cae15ec24 (diff) |
Add ability to filter over feature “age”.
Right now this is only implemented for avverk, mrr and vbk as other
layers don't export the property.
-rw-r--r-- | main.js | 595 | ||||
-rw-r--r-- | style.css | 17 |
2 files changed, 611 insertions, 1 deletions
@@ -126,6 +126,65 @@ const view = new View({ constrainResolution: false, }); +const age_filter_settings = { + active: false, + type: 'relative', + operator: '<=', + quantity: 1, + unit: 'y', + show_unknown: false, + get_relative_date: function(quantity, unit) { + if (quantity == null || isNaN(quantity) || unit == null) { + return null; + } + /* use today noon localtime to avoid issues due to DST when substracting dates */ + const d = new Date(); + d.setHours(12, 0, 0, 0); + switch (unit) { + case 'd': + d.setDate(d.getDate() - quantity); + break; + case 'w': + d.setDate(d.getDate() - 7 * quantity); + break; + case 'm': + d.setMonth(d.getMonth() - quantity); + break; + case 'y': + d.setFullYear(d.getFullYear() - quantity); + break; + default: + return null; + } + return d; + }, + _min_age: null, + _max_age: null, + _date_to_age: function(d) { + if (d == null) { + return null; + } + /* number of days since 1970-01-01; take both dates at 00:00:00.0 UTC */ + return Math.floor(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())/86_400_000); + }, + setup_minmax: function() { + this._min_age = this._max_age = null; + switch (this.type) { + case 'relative': { + const date = this.get_relative_date(this.quantity, this.unit); + const prop = {'<=':'_min_age', '>=':'_max_age'}[this.operator]; + this[prop] = this._date_to_age(date); + break; + } + case 'interval': { + this._min_age = this._date_to_age(this.from); + this._max_age = this._date_to_age(this.to); + break; + } + } + }, +}; + let baseMapLayer = 'topowebb_nedtonad'; (function() { const params = new URLSearchParams(window.location.hash.substring(1)); @@ -165,6 +224,50 @@ let baseMapLayer = 'topowebb_nedtonad'; baseMapLayer = params.get('basemap'); } baseMapSource.setUrl(`https://minkarta.lantmateriet.se/map/topowebbcache?LAYER=${encodeURIComponent(baseMapLayer)}`); + + if (params.has('age-filter')) { + (function(param) { + if (param === '') { + return; + } + const m0 = /^([ +\-]?)([0-9]+)([dwmy])$/.exec(param); + if (m0 != null) { + age_filter_settings.type = 'relative'; + age_filter_settings.operator = (m0[1] === ' ' || m0[1] === '+' || m0[1] === '') ? '>=' + : m0[1] === '-' ? '<=' + : null; + age_filter_settings.quantity = parseInt(m0[2], 10); + age_filter_settings.unit = m0[3]; + age_filter_settings.setup_minmax(); + age_filter_settings.active = true; + return; + } + const m1 = /^([0-9]{8})-([0-9]{8})$/.exec(param); /* YYYYMMDD */ + if (m1 != null) { + const parse_date = (m) => new Date( + parseInt(m.slice(0,4), 10), + parseInt(m.slice(4,6), 10)-1, + parseInt(m.slice(6,8), 10), + 12 /* use 12:00:00.0 like for the <input type="date"> */ + ); + age_filter_settings.type = 'interval'; + age_filter_settings.from = parse_date(m1[1]); + age_filter_settings.to = parse_date(m1[2]); + age_filter_settings.setup_minmax(); + age_filter_settings.active = true; + return; + } + console.log(`Ignoring invalid value for \'age-filter\' parameter: ${param}`); + })(params.get('age-filter')); + } + if (params.has('show-unknown-age')) { + const param = params.get('show-unknown-age'); + if (param === '0') { + age_filter_settings.show_unknown = false; + } else if (param === '1') { + age_filter_settings.show_unknown = true; + } + } })(); @@ -192,6 +295,16 @@ const container = document.getElementById('map-control-container'); const info_backdrop = document.createElement('div'); container0.appendChild(info_backdrop); info_backdrop.id = 'info-modal-backdrop'; + + const age_filter = document.createElement('div'); + age_filter.id = 'age-filter-modal'; + age_filter.classList.add('modal'); + age_filter.setAttribute('tabindex', '-1'); + age_filter.setAttribute('aria-hidden', 'true'); + container0.appendChild(age_filter); + const age_filter_backdrop = document.createElement('div'); + age_filter_backdrop.id = 'age-filter-modal-backdrop'; + container0.appendChild(age_filter_backdrop); })(); /* zoom in/out */ @@ -280,6 +393,7 @@ if (window.location === window.parent.location) { const buttons = Object.fromEntries([ {id: 'layer-selection', title: 'Lagerval', bi: 'stack'}, {id: 'map-legend', title: 'Teckenförklaring', bi: 'list-task'}, + {id: 'age-filter', title: 'Filtrera objekt efter ålder', bi: 'clock-history'}, ].map(function(x) { const div = document.createElement('div'); menu.appendChild(div); @@ -302,6 +416,9 @@ if (window.location === window.parent.location) { Object.keys(buttons).forEach(function(id) { const panel = document.getElementById(id + '-panel'); + if (panel == null) { + return; + } const btn = buttons[id]; btn.onclick = function(event) { if (btn.getAttribute('aria-expanded') === 'true') { @@ -4426,7 +4543,21 @@ const [mapLayers, featureOverlayLayer] = (function() { declutter: false, visible: isVisible(k), style: function(feature, resolution) { - const style = styles[k + '.' + feature.getProperties().layer]; + const properties = feature.getProperties(); + if (age_filter_settings.active) { + /* TODO avoid doing this checks for each feature; instead, set up a + * different style function if age_filter_settings.active */ + const age = properties.age; + if (age == null) { + if (!age_filter_settings.show_unknown) { + return null; + } + } else if ((age_filter_settings._min_age !== null && age < age_filter_settings._min_age) || + (age_filter_settings._max_age !== null && age > age_filter_settings._max_age)) { + return null; + } + } + const style = styles[k + '.' + properties.layer]; if (!Array.isArray(style)) { return style; } else { @@ -4905,6 +5036,468 @@ const infoMetadataAccordions = []; })(); })(); +/* age filter panel */ +(function() { + const panel = document.getElementById('age-filter-modal'); + + const dialog_setup = (function() { + const dialog = document.createElement('div'); + dialog.classList.add('modal-dialog', 'modal-dialog-centered'); + panel.appendChild(dialog); + + const content = document.createElement('div'); + content.classList.add('modal-content'); + dialog.appendChild(content); + + const header = document.createElement('div'); + header.classList.add('modal-header'); + content.appendChild(header); + + const title = document.createElement('div'); + title.classList.add('h4', 'm-0'); + header.appendChild(title); + title.innerHTML = 'Filtrera objekt efter ålder'; + + const btn_close = document.createElement('button'); + btn_close.classList.add('btn-close'); + btn_close.type = 'button'; + btn_close.title = 'Stäng'; + btn_close.setAttribute('aria-label', btn_close.title); + btn_close.setAttribute('data-bs-dismiss', 'modal'); + header.appendChild(btn_close); + + const body = document.createElement('div'); + body.classList.add('modal-body'); + content.appendChild(body); + + const p = document.createElement('p'); + p.classList.add('small', 'text-muted', 'age-filter-infotext'); + body.appendChild(p); + p.appendChild(document.createTextNode('Här kan du filtrera bort objekt som är äldre eller ' + + 'yngre än ett valt datum. I nulaget gäller filtret endast objekt inom ')); + ['mineralrättigheter', 'skogsbruk', 'vindbruk'].forEach(function(l, idx, arr) { + if (idx > 0) { + p.appendChild(document.createTextNode(idx < arr.length-1 ? ', ' : ' eller ')); + } + const i = document.createElement('i'); + i.innerHTML = l; + p.appendChild(i); + }); + p.appendChild(document.createTextNode(' skikter.')); + + const form = document.createElement('form'); + form.action = '#'; + form.id = 'age-filter-form'; + body.appendChild(form); + + const btn_group = document.createElement('div'); + btn_group.classList.add('btn-group'); + btn_group.setAttribute('role', 'group'); + btn_group.setAttribute('aria-label', 'Välj filtreringstyp'); + form.appendChild(btn_group); + + const p2 = document.createElement('p'); + form.appendChild(p2); + p2.innerHTML = 'Visa endast objekt med datum:'; + + const type_choices = Object.fromEntries([ + { id: 'relative', text: 'Relativt datum' }, + { id: 'interval', text: 'Intervall' }, + ].map(function(x) { + const radio = document.createElement('input'); + radio.classList.add('btn-check'); + radio.type = 'radio'; + radio.name = 'age-filter-type'; + radio.id = 'age-filter-type-' + x.id; + radio.required = true; + radio.setAttribute('aria-expanded', 'false'); + radio.setAttribute('autocomplete', 'off'); + btn_group.appendChild(radio); + + const lbl = document.createElement('label'); + lbl.classList.add('btn', 'btn-primary'); + lbl.setAttribute('for', radio.id); + lbl.appendChild(document.createTextNode(x.text)); + btn_group.appendChild(lbl); + + const div = document.createElement('div'); + div.classList.add('d-none', 'age-filter-settings'); + div.setAttribute('aria-hidden', 'true'); + form.appendChild(div); + + return [x.id, { radio: radio, div: div } ]; + })); + + Object.values(type_choices).forEach(function(type_choice) { + type_choice.radio.onclick = function(event) { + const radio = event.target; + radio.checked = true; + radio.setAttribute('aria-expanded', 'true'); + + Object.values(type_choices).forEach(function(x) { + const isSame = radio.isEqualNode(x.radio); + if (isSame) { + x.div.classList.remove('d-none'); + x.div.setAttribute('aria-hidden', 'false'); + } else { + x.div.classList.add('d-none'); + x.div.setAttribute('aria-hidden', 'true'); + x.radio.checked = false; + x.radio.setAttribute('aria-expanded', 'false'); + } + const inputs = x.div.getElementsByTagName('input'); + for (let i = 0; i < inputs.length; i++) { + inputs[i].required = isSame; + } + }); + }; + }); + + const create_select = function(parentNode, select_label, options) { + const select = document.createElement('select'); + select.classList.add('form-select'); + select.setAttribute('aria-label', select_label); + parentNode.appendChild(select); + return [ + select, + Object.fromEntries(options.map(function(x) { + const option = document.createElement('option'); + option.appendChild(document.createTextNode(x.text)); + option.value = x.id; + select.appendChild(option); + return [x.id, option]; + })) + ]; + }; + + const format_date = function(d, s) { /* YYYY-MM-DD or YYYYMMDD */ + return d.getFullYear() .toString().padStart(4, '0') + (s ?? '-') + + (d.getMonth()+1).toString().padStart(2, '0') + (s ?? '-') + + d.getDate() .toString().padStart(2, '0'); + }; + const parse_date = (m) => new Date( /* YYYY-MM-DD */ + parseInt(m.slice(0,4), 10), + parseInt(m.slice(5,7), 10)-1, + parseInt(m.slice(8,10),10), + 12 /* use 12:00:00.0 like for URL parameters */ + ); + + (function(type_choice) { + const div = document.createElement('div'); + div.classList.add('input-group'); + type_choice.div.appendChild(div); + + type_choice.operator = create_select(div, 'Före eller efter datumet', [ + { id: '<=', text: 'Tidigast' }, + { id: '>=', text: 'Senast' }, + ]); + + const input_quantity = type_choice.quantity = document.createElement('input'); + input_quantity.classList.add('form-control'); + input_quantity.type = 'number'; + input_quantity.min = 0; + input_quantity.max = 65535; + input_quantity.placeholder = 'Antal'; + input_quantity.setAttribute('aria-label', input_quantity.placeholder); + div.appendChild(input_quantity); + + type_choice.unit = create_select(div, 'Enhet', [ + { id: 'd', text: 'dagar' }, + { id: 'w', text: 'veckor' }, + { id: 'm', text: 'månader' }, + { id: 'y', text: 'år' }, + ]); + + const span = document.createElement('span'); + span.classList.add('input-group-text'); + div.appendChild(span); + span.innerHTML = 'sedan'; + + const p = document.createElement('p'); + p.classList.add('small', 'text-muted', 'invisible'); + type_choice.div.appendChild(p); + p.appendChild(document.createTextNode('Det vill säga ')); + const span_date = document.createElement('span'); + p.appendChild(span_date); + p.appendChild(document.createTextNode(' eller ')); + const span_direction = document.createElement('span'); + p.appendChild(span_direction); + p.appendChild(document.createTextNode('.')); + + type_choice._update_helptext = function() { + const d = age_filter_settings.get_relative_date( + parseInt(type_choice.quantity.value, 10), + type_choice.unit[0].value + ); + const operator = type_choice.operator[0].value; + if (d == null || operator === '') { + p.classList.add('invisible'); + return null; + } else { + /* update help text */ + /* TODO auto update the date passed midnight (if the modal is open) */ + span_date.innerHTML = format_date(d); + span_direction.innerHTML = { '>=':'tidigare', '<=':'senare' }[operator]; + p.classList.remove('invisible'); + return d; + } + }; + + type_choice.operator[0].onchange = input_quantity.onchange + = type_choice.unit[0].onchange + = function(event) { + const d = type_choice._update_helptext(); + if (d != null) { + /* propagate to interval tab */ + const operator = type_choice.operator[0].value; + const [d1, d2] = { '>=': [new Date(1900, 0, 1, 12), d], /* between 1900-01-01 and d */ + '<=': [d, new Date()], /* between d and today */ + }[operator]; + type_choices.interval.from.value = format_date(d1); + type_choices.interval.to.value = format_date(d2); + } + }; + })(type_choices.relative); + + (function(type_choice) { + const div = document.createElement('div'); + div.classList.add('input-group'); + type_choice.div.appendChild(div); + + const span1 = document.createElement('span'); + span1.classList.add('input-group-text'); + span1.innerHTML = 'Mellan'; + span1.id = 'age-filter-interval-from'; + div.appendChild(span1); + + const date1 = type_choice.from = document.createElement('input'); + date1.classList.add('form-control'); + date1.type = 'date'; + date1.setAttribute('aria-label', 'Intervallstart'); + date1.setAttribute('aria-describedby', span1.id); + div.appendChild(date1); + + const span2 = document.createElement('span'); + span2.classList.add('input-group-text'); + span2.innerHTML = 'och'; + span2.id = 'age-filter-interval-to'; + div.appendChild(span2); + + const date2 = type_choice.to = document.createElement('input'); + date2.classList.add('form-control'); + date2.type = 'date'; + date2.setAttribute('aria-label', 'Intervallstop'); + date2.setAttribute('aria-describedby', span2.id); + div.appendChild(date2); + + /* propagate to relative tab by trying to preserve the operator and unit */ + const propagate_to_relative = function() { + let d; + switch (type_choices.relative.operator[0].value) { + case '<=': + d = parse_date(date1.value); + break; + case '>=': + d = parse_date(date2.value); + break; + } + const delta = (new Date().getTime() - d.getTime()) / 86_400_000.; + let v; + switch (type_choices.relative.unit[0].value) { + case 'd': + v = delta; + break; + case 'w': + v = delta/7; + break; + case 'm': + v = delta*12/365.25; + break; + case 'y': + v = delta/365.25; + break; + } + if (v != null) { + type_choices.relative.quantity.value = Math.round(v); + type_choices.relative._update_helptext(); + } + }; + + /* make sure that from_date ≤ to_date */ + date1.onchange = function(event) { + if (date1.value !== '' && (date2.value === '' || date1.value > date2.value)) { + date2.value = date1.value; + } + propagate_to_relative(); + }; + date2.onchange = function(event) { + if (date2.value !== '' && (date1.value === '' || date1.value > date2.value)) { + date1.value = date2.value; + } + propagate_to_relative(); + }; + })(type_choices.interval); + + const show_unknown_age = (function() { + const div = document.createElement('div'); + div.classList.add('form-check', 'form-switch'); + form.appendChild(div); + + const checkbox = document.createElement('input'); + checkbox.classList.add('form-check-input'); + checkbox.type = 'checkbox'; + checkbox.id = 'age-filter-show-unknown'; + checkbox.setAttribute('role', 'switch'); + checkbox.checked = age_filter_settings.show_unknown; + div.appendChild(checkbox); + + const lbl = document.createElement('label'); + lbl.classList.add('form-check-label'); + lbl.setAttribute('for', checkbox.id); + lbl.innerHTML = 'Visa även objekt med okänd datum.'; + div.appendChild(lbl); + + return checkbox; + })(); + + const footer = document.createElement('div'); + footer.classList.add('modal-footer'); + content.appendChild(footer); + + const btn_cancel = document.createElement('button'); + btn_cancel.classList.add('btn', 'btn-secondary'); + btn_cancel.type = 'button'; + btn_cancel.innerHTML = 'Återställ'; + footer.appendChild(btn_cancel); + + const btn_apply = document.createElement('input'); + btn_apply.classList.add('btn', 'btn-primary'); + btn_apply.type = 'submit'; + btn_apply.value = 'Filter'; + btn_apply.setAttribute('form', form.id); + footer.appendChild(btn_apply); + + btn_cancel.onclick = function(event) { + /* deactivate deactivate the filter but preserve its settings */ + age_filter_settings.active = false; + ['avverk', 'mrr', 'vbk'].forEach((l) => mapLayers[l]?.changed()); + + const params = new URLSearchParams(window.location.hash.substring(1)); + params.delete('age-filter'); + params.delete('show-unknown-age'); + location.hash = '#' + params.toString(); + + modal.hide(); + }; + + form.onsubmit = function(event) { + event.preventDefault(); + const [filter_type, filter_settings] = Object.entries(type_choices).filter(function([id, x]) { + return x.radio.checked; + })[0]; + let param; + age_filter_settings._min_age = age_filter_settings._max_age = null; + switch (filter_type) { + case 'relative': { + const operator = age_filter_settings.operator = filter_settings.operator[0].value; + age_filter_settings.quantity = parseInt(filter_settings.quantity.value, 10); + age_filter_settings.unit = filter_settings.unit[0].value; + param = {'<=':'-', '>=':''}[operator]; + param += age_filter_settings.quantity.toString() + age_filter_settings.unit; + break; + } + case 'interval': { + const date1 = age_filter_settings.from = parse_date(filter_settings.from.value); + const date2 = age_filter_settings.to = parse_date(filter_settings.to.value); + param = format_date(date1, '') + '-' + format_date(date2, ''); + break; + } + default: + return; + } + age_filter_settings.type = filter_type; + age_filter_settings.show_unknown = show_unknown_age.checked; + age_filter_settings.setup_minmax(); + age_filter_settings.active = true; + /* TODO auto update the filter passed midnight (if active) */ + ['avverk', 'mrr', 'vbk'].forEach((l) => mapLayers[l]?.changed()); + + const params = new URLSearchParams(window.location.hash.substring(1)); + params.set('age-filter', param); + params.set('show-unknown-age', show_unknown_age.checked ? '1' : '0'); + location.hash = '#' + params.toString(); + + modal.hide(); + }; + + /* Now that all elements have been added to the form, click the radio + * button to show the relevant <div>, mark its fields as required and + * fills them. The function is run whenever the modal is shown. */ + return function() { + const type_choice = type_choices[age_filter_settings.type]; + type_choice.radio.click(); + + switch (age_filter_settings.type) { + case 'relative': { + Object.entries(type_choice.operator[1]).map(function([id, option]) { + option.selected = id === age_filter_settings.operator; + }); + type_choice.quantity.value = age_filter_settings.quantity.toString(); + Object.entries(type_choice.unit[1]).map(function([id, option]) { + option.selected = id === age_filter_settings.unit; + }); + type_choice.quantity.dispatchEvent(new Event('change')); /* propagate to absolute */ + break; + } + case 'interval': { + type_choice.from.value = format_date(age_filter_settings.from); + type_choice.to.value = format_date(age_filter_settings.to); + type_choice.from.dispatchEvent(new Event('change')); /* propagate to relative */ + break; + } + } + }; + })(); + + const modal = new Modal(panel, { + backdrop: false, + }); + + const backdrop = document.getElementById('age-filter-modal-backdrop'); + backdrop.onclick = function(event) { + modal.hide(); + }; + + const btn = document.getElementById('age-filter-button').getElementsByTagName('button')[0]; + if (age_filter_settings.active) { + btn.classList.replace('btn-light', 'btn-dark'); + } + panel.addEventListener('show.bs.modal', function() { + backdrop.classList.add('modal-backdrop', 'show'); + btn.setAttribute('aria-expanded', 'true'); + btn.classList.replace('btn-light', 'btn-dark'); + }); + panel.addEventListener('hide.bs.modal', function() { + /* XXX workaround for https://github.com/twbs/bootstrap/issues/41005#issuecomment-2585390544 */ + const activeElement = document.activeElement; + if (activeElement instanceof HTMLElement) { + activeElement.blur(); + } + }); + panel.addEventListener('hidden.bs.modal', function() { + if (!age_filter_settings.active) { + btn.classList.replace('btn-dark', 'btn-light'); + } + btn.setAttribute('aria-expanded', 'false'); + backdrop.classList.remove('modal-backdrop', 'show'); + }); + + btn.onclick = function(event) { + dialog_setup(); + modal.show(); + }; +})(); + /* popup and feature overlays */ (function() { const popupOverlay = new Overlay({ @@ -267,6 +267,7 @@ body.inprogress { @media screen and (max-width: 200px) { #layer-selection-button, #map-legend-button, + #age-filter-button, #export-to-image, #layer-selection-panel, #map-legend-panel, @@ -463,6 +464,22 @@ body.inprogress { background-color: var(--bs-list-group-action-hover-bg); } +#age-filter-modal .modal-body .btn-group { + width: 100%; +} +#age-filter-modal .age-filter-infotext { + margin: 0 0 var(--bs-modal-padding) 0; +} +#age-filter-form > p { + margin: var(--bs-modal-padding) 0; +} +#age-filter-form .age-filter-settings { + margin: 0 0 var(--bs-modal-padding) 0; +} +#age-filter-form .age-filter-settings p { + margin: 0; +} + .popover { --bs-popover-header-padding-x: .75rem; --bs-popover-header-padding-y: .5rem; |