From 0fd5cca7f81d27b297ae63e72d1c23dd6f75b415 Mon Sep 17 00:00:00 2001 From: ASC95 <16790624+ASC95@users.noreply.github.com> Date: Fri, 11 Oct 2024 00:53:46 -0400 Subject: [PATCH 1/2] improved and automated the behavior of adding components to the circuit editor --- .gitignore | 3 +- omf/geo.py | 3 +- omf/static/geoJsonMap/v3/featureController.js | 36 +- .../geoJsonMap/v3/featureDropdownDiv.js | 2 +- omf/static/geoJsonMap/v3/featureEditModal.js | 730 ++++++++---------- omf/static/geoJsonMap/v3/featureGraph.js | 53 +- omf/static/geoJsonMap/v3/leafletLayer.js | 2 +- omf/static/geoJsonMap/v3/searchModal.js | 2 +- .../v4/mvc/models/validity/validity.js | 48 ++ .../v4/mvc/models/validity/validity.test.js | 91 +++ 10 files changed, 537 insertions(+), 433 deletions(-) create mode 100644 omf/static/geoJsonMap/v4/mvc/models/validity/validity.js create mode 100644 omf/static/geoJsonMap/v4/mvc/models/validity/validity.test.js diff --git a/.gitignore b/.gitignore index 98f9fdc8c..b3408dced 100644 --- a/.gitignore +++ b/.gitignore @@ -66,4 +66,5 @@ omf/solvers/reopt_jl/testFiles/REoptInputs.json omf/solvers/reopt_jl/reopt_jl.so omf/solvers/reopt_jl/julia-1.9.4/* instantiated.txt -omf/solvers/protsetopt/testFiles/ \ No newline at end of file +omf/solvers/protsetopt/testFiles/ +.vite/ \ No newline at end of file diff --git a/omf/geo.py b/omf/geo.py index e26bf6355..6a9250948 100644 --- a/omf/geo.py +++ b/omf/geo.py @@ -657,7 +657,6 @@ def map_omd(omd_path, output_dir, open_browser=False, showAddNewObjectsButton=Tr ''' Create an HTML page of the GeoJSON circuit editor without Flask ''' - # - Load feeder data with open(omd_path) as f: omd = json.load(f) @@ -670,6 +669,8 @@ def map_omd(omd_path, output_dir, open_browser=False, showAddNewObjectsButton=Tr # - Load JavaScript main_js_filepath = (pathlib.Path(omf.omfDir).resolve(True) / 'static' / 'geoJsonMap' / 'v3' / 'main.js').resolve(True) all_js_filepaths = list((pathlib.Path(omf.omfDir).resolve(True) / 'static' / 'geoJsonMap').glob('**/*.js')) + # - Filter out .test.js files + all_js_filepaths = list(filter(lambda p: not str(p).endswith('.test.js'), all_js_filepaths)) all_js_filepaths.remove(main_js_filepath) all_js_filepaths.append(main_js_filepath) all_js_file_content = [] diff --git a/omf/static/geoJsonMap/v3/featureController.js b/omf/static/geoJsonMap/v3/featureController.js index 120126bc3..ed5df6daa 100644 --- a/omf/static/geoJsonMap/v3/featureController.js +++ b/omf/static/geoJsonMap/v3/featureController.js @@ -27,24 +27,36 @@ class FeatureController { // implements ControllerInterface * @returns {undefined} */ addObservables(observables) { - observables.forEach(ob => { - const key = (this.observableGraph.getMaxKey() + 1).toString(); - ob.setProperty('treeKey', key, 'meta'); - this.observableGraph.insertObservable(ob); - if (!ob.isConfigurationObject()) { - LeafletLayer.createAndGroupLayer(ob, this); + for (const observable of observables) { + const treeKey = (this.observableGraph.getMaxKey() + 1).toString(); + observable.setProperty('treeKey', treeKey, 'meta'); + // - When a component is added, the easiest way to ensure a unique, descriptive name is to use its treeKey. Since the treeKey must be set + // here, it also makes sense to set the name here, even, potentially, for mass add + // - All components have a name due to line 1327 of geo.py + if (observable.hasProperty('name')) { + let name = observable.getProperty('name'); + let key = treeKey; + while (this.observableGraph.getObservables(ob => ob.hasProperty('name') && ob.getProperty('name') === name).length > 0) { + name = `${name}:${key}`; + key += 1; + } + observable.setProperty('name', name); + } + this.observableGraph.insertObservable(observable); + if (!observable.isConfigurationObject()) { + LeafletLayer.createAndGroupLayer(observable, this); } - if (ob.isLine()) { - ob.getObservers().filter(ob => ob instanceof LeafletLayer)[0].getLayer().bringToBack(); + if (observable.isLine()) { + observable.getObservers().filter(ob => ob instanceof LeafletLayer)[0].getLayer().bringToBack(); } - if (ob.isChild()) { - const parentKey = this.observableGraph.getKey(ob.getProperty('parent'), ob.getProperty('treeKey', 'meta')); - const parentChildLineFeature = this.observableGraph.getParentChildLineFeature(parentKey, key); + if (observable.isChild()) { + const parentKey = this.observableGraph.getKey(observable.getProperty('parent'), observable.getProperty('treeKey', 'meta')); + const parentChildLineFeature = this.observableGraph.getParentChildLineFeature(parentKey, treeKey); this.observableGraph.insertObservable(parentChildLineFeature); LeafletLayer.createAndGroupLayer(parentChildLineFeature, this); parentChildLineFeature.getObservers().filter(ob => ob instanceof LeafletLayer)[0].getLayer().bringToBack(); } - }); + } } /** diff --git a/omf/static/geoJsonMap/v3/featureDropdownDiv.js b/omf/static/geoJsonMap/v3/featureDropdownDiv.js index 04f0aac81..7b22097e2 100644 --- a/omf/static/geoJsonMap/v3/featureDropdownDiv.js +++ b/omf/static/geoJsonMap/v3/featureDropdownDiv.js @@ -163,7 +163,7 @@ class FeatureDropdownDiv { const outerFunc = function(div) { if (div.lastChild.classList.contains('-expanded')) { div.firstChild.getElementsByClassName('icon')[0].classList.add('-rotated'); - that.#featureEditModal = new FeatureEditModal([that.#observable], that.#controller); + that.#featureEditModal = new FeatureEditModal(that.#observable, that.#controller); div.lastChild.append(that.#featureEditModal.getDOMElement()); } else { div.firstChild.getElementsByClassName('icon')[0].classList.remove('-rotated'); diff --git a/omf/static/geoJsonMap/v3/featureEditModal.js b/omf/static/geoJsonMap/v3/featureEditModal.js index 5534202cb..aee681409 100644 --- a/omf/static/geoJsonMap/v3/featureEditModal.js +++ b/omf/static/geoJsonMap/v3/featureEditModal.js @@ -4,31 +4,32 @@ import { PropTable } from '../v4/ui-components/prop-table/prop-table.js'; import { FeatureController } from './featureController.js'; import { LeafletLayer } from './leafletLayer.js'; import { IconLabelButton } from '../v4/ui-components/iconlabel-button/iconlabel-button.js'; +import { Validity } from '../v4/mvc/models/validity/validity.js'; class FeatureEditModal { // implements ObserverInterface, ModalInterface #controller; // - ControllerInterface instance #propTable; // - A PropTable instance - #observables; // - An array of ObservableInterface instances + #observable; // - A single ObservableInterface instance #removed; // - Whether this FeatureEditModal instance has already been deleted static #nonDeletableProperties = ['name', 'object', 'from', 'to', 'parent', 'latitude', 'longitude', 'treeKey', 'CMD_command']; /** - * @param {Array} observables - an array of ObservableInterface instances + * @param {Feature} observable - a single ObservableInterface instance * @param {FeatureController} controller - a ControllerInterface instance * @returns {undefined} */ - constructor(observables, controller) { - if (!(observables instanceof Array)) { - throw TypeError('"observables" argumnet must be an Array.'); + constructor(observable, controller) { + if (!(observable instanceof Feature)) { + throw TypeError('The "observable" argumnet must be instanceof Feature.'); } if (!(controller instanceof FeatureController)) { - throw Error('"controller" argument must be instanceof FeatureController'); + throw Error('The "controller" argument must be instanceof FeatureController.'); } this.#controller = controller; this.#propTable = null; - this.#observables = observables; - this.#observables.forEach(ob => ob.registerObserver(this)); + this.#observable = observable; + this.#observable.registerObserver(this); this.#removed = false; this.renderContent(); } @@ -46,21 +47,10 @@ class FeatureEditModal { // implements ObserverInterface, ModalInterface handleDeletedObservable(observable) { // - The function signature above is part of the ObserverInterface API. The implementation below is not if (!(observable instanceof Feature)) { - throw TypeError('"observable" argument must be instanceof Feature.'); + throw TypeError('The "observable" argument must be instanceof Feature.'); } if (!this.#removed) { - observable.removeObserver(this); - const index = this.#observables.indexOf(observable); - if (index > -1) { - this.#observables.splice(index, 1); - } else { - throw Error('The observable was not found in this.#observables.'); - } - if (this.#observables.length === 0) { - this.remove(); - } else { - this.refreshContent(); - } + this.remove(); } } @@ -82,10 +72,10 @@ class FeatureEditModal { // implements ObserverInterface, ModalInterface handleUpdatedCoordinates(observable, oldCoordinates) { // - The function signature above is part of the ObserverInterface API. The implementation below is not if (!(observable instanceof Feature)) { - throw TypeError('"observable" argument must be instanceof Feature.'); + throw TypeError('The "observable" argument must be instanceof Feature.'); } if (!(oldCoordinates instanceof Array)) { - throw TypeError('"oldCoordinates" argument must be an array.'); + throw TypeError('The "oldCoordinates" argument must be an array.'); } this.refreshContent(); } @@ -102,13 +92,13 @@ class FeatureEditModal { // implements ObserverInterface, ModalInterface handleUpdatedProperty(observable, propertyKey, oldPropertyValue, namespace='treeProps') { // - The function signature above is part of the ObserverInterface API. The implementation below is not if (!(observable instanceof Feature)) { - throw TypeError('"observable" argument must be instanceof Feature.'); + throw TypeError('The "observable" argument must be instanceof Feature.'); } if (typeof propertyKey !== 'string') { - throw TypeError('"propertyKey" argument must be a string.'); + throw TypeError('The "propertyKey" argument must be a string.'); } if (typeof namespace !== 'string') { - throw TypeError('"namespace" argument must be a string.'); + throw TypeError('The "namespace" argument must be a string.'); } this.refreshContent(); } @@ -135,9 +125,7 @@ class FeatureEditModal { // implements ObserverInterface, ModalInterface refreshContent() { const tableState = {}; // - Don't grab a row that was added with the "+" button, if it exists - //[...this.#modal.divElement.getElementsByTagName('tr')].filter(tr => tr.querySelector('span[data-property-key]') !== null).forEach(tr => { [...this.#propTable.div.getElementsByTagName('tr')].filter(tr => tr.querySelector('span[data-property-key]') !== null).forEach(tr => { - const span = tr.getElementsByTagName('span')[0]; let namespace = span.dataset.propertyNamespace; // - latitude and longitude don't exist in any property namespace @@ -152,66 +140,51 @@ class FeatureEditModal { // implements ObserverInterface, ModalInterface } }); const tableKeys = Object.keys(tableState); - const observablesState = this.#getKeyToValuesMapping(); - for (const [key, ary] of Object.entries(observablesState.meta)) { - const valueElement = tableState[key].propertyValueElement; - if (valueElement instanceof HTMLSpanElement) { - valueElement.textContent = ary.join(','); - } else { - // - Only the treeKey should be in the meta namespace and the treeKey only ever displays with a span - throw Error() - } - } - for (const [key, ary] of Object.entries(observablesState.treeProps)) { - // - First, compare the observables' state to the table state. If the observables' state has a property that is not in the table state, + // - Don't worry about inserting a new property into the table in sorted order. It will be sorted the next time that the table is rendered + for (const [key, val] of Object.entries(this.#observable.getProperties('treeProps'))) { + // - First, compare the observable's state to the table state. If the observable's state has a property that is not in the table state, // add a row to the table if (!tableKeys.includes(key)) { - // - Don't let unintended properties show up when a FeatureEditModal refreshes due to a marker being dragged - if (['from', 'to'].includes(key)) { - if (!this.#observables.every(ob => ob.isLine() && !ob.isParentChildLine())) { - continue; - } - } - if (key === 'type' && !this.#observables.every(ob => ob.isParentChildLine())) { - continue; - } - if (key === 'parent' && !this.#observables.every(ob => ob.isChild())) { + // - Don't display "from", "to", or "type" for parent-child lines + if (['from', 'to', 'type'].includes(key) && this.#observable.isParentChildLine()) { continue; } const keySpan = document.createElement('span'); keySpan.textContent = key; keySpan.dataset.propertyKey = key; keySpan.dataset.propertyNamespace = 'treeProps'; - //this.#modal.insertTBodyRow([this.#getDeletePropertyButton(key), keySpan, this.#getValueTextInput(key, ary)], 'beforeEnd'); - this.#propTable.insertTBodyRow({elements: [this.#getDeletePropertyButton(key), keySpan, this.#getValueTextInput(key, ary)], position: 'beforeEnd'}); - + this.#propTable.insertTBodyRow({elements: [this.#getDeletePropertyButton(key), keySpan, this.#getValueTextInput(key, val)], position: 'beforeEnd'}); + // - Second, if the table has the already key, update the table } else { - // - If the table has the key, update the display of the values for that key const valueElement = tableState[key].propertyValueElement; if (valueElement instanceof HTMLInputElement) { - valueElement.replaceWith(this.#getValueTextInput(key, ary)); + valueElement.value = val.toString(); } else if (valueElement instanceof HTMLSpanElement) { - valueElement.textContent = ary.join(', '); + valueElement.textContent = val.toString(); } } } - for (const [key, ary] of Object.entries(observablesState.coordinates)) { - // - Don't add latitude or longitude rows to tables that didn't already have those rows, just update the existing inputs + for (const key of ['latitude', 'longitude']) { + // - No lines or configuration objects should ever display "latitude" or "longitude" in their table to begin with if (tableKeys.includes(key)) { + const [lon, lat] = this.#observable.getCoordinates(); const valueElement = tableState[key].propertyValueElement; if (valueElement instanceof HTMLInputElement) { - valueElement.replaceWith(this.#getValueTextInput(key, ary)); + if (key === 'latitude') { + valueElement.value = lat; + } else { + valueElement.value = lon; + } } } } - // - Now compare the table's state to the observables' state. If the table's state has a property that is not in the observables' state, + // - Now compare the table's state to the observable's state. If the table's state has a property that is not in the observable's state, // remove the row from the table - const observablesKeysFromAllNamespaces = []; - for (const obj of Object.values(observablesState)) { - observablesKeysFromAllNamespaces.push(...Object.keys(obj)) - } for (const [key, obj] of Object.entries(tableState)) { - if (!observablesKeysFromAllNamespaces.includes(key)) { + if (['latitude', 'longitude'].includes(key)) { + continue; + } + if (!this.#observable.hasProperty(key, obj.propertyNamespace)) { obj.propertyTableRow.remove(); } } @@ -222,8 +195,8 @@ class FeatureEditModal { // implements ObserverInterface, ModalInterface */ remove() { if (!this.#removed) { - this.#observables.forEach(ob => ob.removeObserver(this)); - this.#observables = null; + this.#observable.removeObserver(this); + this.#observable = null; this.#propTable.div.remove(); this.#removed = true; } @@ -236,10 +209,10 @@ class FeatureEditModal { // implements ObserverInterface, ModalInterface renderContent() { const propTable = new PropTable(); propTable.div.classList.add('featureEditModal'); - if (this.#observables[0].hasProperty('treeKey', 'meta')) { - this.#renderOmfFeatures(propTable); + if (this.#observable.hasProperty('treeKey', 'meta')) { + this.#renderOmfFeature(propTable); } else { - this.#renderArbitraryFeatures(propTable); + this.#renderArbitraryFeature(propTable); } propTable.div.addEventListener('click', function(e) { // - Don't let clicks on the table cause the popup to close @@ -259,100 +232,88 @@ class FeatureEditModal { // implements ObserverInterface, ModalInterface // ********************* /** - * @returns {boolean} + * @param {Event} event - a standard mouse click event */ - #componentsStateIsValid() { - for (const ob of this.#observables) { - for (const [k, v] of Object.entries(ob.getProperties('treeProps'))) { - if (!this.#valueTextInputIsValid(k, v)) { - return false; - } + #addObservableWithClick(event) { + const mapDiv = document.getElementById('map'); + mapDiv.style.cursor = 'crosshair'; + LeafletLayer.map.on('click', (e) => { + // - If the user clicks on an "add" button multiple times or clicks on a node add button followed by a line add button, it's fine. This + // event listener is only registered on each button once per button. As soon as the first button is clicked, that's the event handler + // that will execute first on the map. Since this event handler also removes all "click" event handlers from the map, all subsequent add + // operation event handlers will be removed before they exeucte + LeafletLayer.map.off('click'); + mapDiv.style.removeProperty('cursor'); + try { + const observable = this.#getObservableFromComponent(this.#observable, [e.latlng.lat, e.latlng.lng]); + this.#controller.addObservables([observable]); + } catch (e) { + alert(e.message); } - } - return true; + }); } /** - * - Add a configuration object to the data. Components arrive with bad data. That's why I have to validate a component twice: once during any - * value input changes and once when the button is clicked. I assume that regular features arrive with valid data. That's why I only validate - * when an input changes + * - Add a configuration object to the data * @returns {HTMLButtonElement} */ #getAddConfigurationObjectButton() { - let button = new IconLabelButton({paths: IconLabelButton.getCirclePlusPaths(), viewBox: '0 0 24 24', text: 'Add config object'}); + let button = new IconLabelButton({paths: IconLabelButton.getCirclePlusPaths(), viewBox: '0 0 24 24', text: 'Add a config object'}); button.button.classList.add('-green'); button.button.getElementsByClassName('icon')[0].classList.add('-white'); button.button.getElementsByClassName('label')[0].classList.add('-white'); button = button.button; button.addEventListener('click', () => { - if (this.#componentsStateIsValid()) { - this.#controller.addObservables(this.#observables.map(ob => this.#getObservableFromComponent(ob))); + try { + const observable = this.#getObservableFromComponent(this.#observable); + this.#controller.addObservables([observable]); + } catch (e) { + alert(e.message); } }); return button; } /** - * - Add a line to the map by inputing "from" and "to" values and then clicking this button + * - Add a line. If the "from" and "to" values are valid, use them. If they aren't, grab the two closest nodes to the click on the map and connect + * the line to those nodes * @returns {HTMLButtonElement} */ - #getAddLineWithFromToButton() { - let button = new IconLabelButton({paths: IconLabelButton.getCirclePlusPaths(), viewBox: '0 0 24 24', text: 'Add line with from/to', tooltip: 'Add a new line by entering the name of a node for the "from" property and entering the name of another node in the "to" property'}); + #getAddLineButton() { + let button = new IconLabelButton({ + paths: IconLabelButton.getCirclePlusPaths(), + viewBox: '0 0 24 24', + text: 'Add a line', + tooltip: 'Add a new line to the data by clicking on this button then clicking on the map. If valid values for the "from" and "to"' + + ' properties are specified, the line will connect to those objects. Otherwise, the line will connect to the two nodes that were' + + ' closest to the map click.' + }); button.button.classList.add('-green'); button.button.getElementsByClassName('icon')[0].classList.add('-white'); button.button.getElementsByClassName('label')[0].classList.add('-white'); button = button.button; - button.addEventListener('click', () => { - if (this.#componentsStateIsValid()) { - this.#controller.addObservables(this.#observables.map(ob => this.#getObservableFromComponent(ob))); - } - }); + button.addEventListener('click', this.#addObservableWithClick.bind(this)); return button; } - /** - * - Add a node to the map by inputing coordinates and then clicking this button - * @returns {HTMLDivElement} - */ - //#getAddNodeWithCoordinatesDiv() { - // const btn = this.#getWideButton(); - // btn.classList.add('add'); - // btn.appendChild(getCirclePlusSvg()); - // const span = document.createElement('span'); - // span.textContent = 'Add with coordinates'; - // btn.appendChild(span); - // btn.addEventListener('click', () => { - // if (this.#componentsStateIsValid()) { - // this.#controller.addObservables(this.#observables.map(ob => this.#getObservableFromComponent(ob))); - // } - // }); - // const div = this.#getWideButtonDiv(); - // div.appendChild(btn); - // return div; - //} - /** * - Add a node to the map by clicking on the map * @returns {HTMLDivElement} */ - #getAddNodeWithMapClickButton() { - let button = new IconLabelButton({paths: IconLabelButton.getCirclePlusPaths(), viewBox: '0 0 24 24', text: 'Add object with map click', tooltip: 'Click this button, then click on the map to create a new instance of this object'}); + #getAddNodeButton() { + let button = new IconLabelButton({ + paths: IconLabelButton.getCirclePlusPaths(), + viewBox: '0 0 24 24', + text: 'Add a node', + tooltip: 'Add a new node to the data by clicking on this button then clicking on the map. If the node is a child and a valid value for' + + ' the "parent" property is specified, the child will connect to that parent. Otherwise, the child will connect to parent that was' + + ' closest to the map click.' + }); button.button.classList.add('-green'); button.button.getElementsByClassName('icon')[0].classList.add('-white'); button.button.getElementsByClassName('label')[0].classList.add('-white'); button = button.button; - const that = this; - button.addEventListener('click', () => { - const mapDiv = document.getElementById('map'); - mapDiv.style.cursor = 'crosshair'; - LeafletLayer.map.on('click', function(e) { - LeafletLayer.map.off('click'); - mapDiv.style.removeProperty('cursor'); - if (that.#componentsStateIsValid()) { - that.#controller.addObservables(that.#observables.map(ob => that.#getObservableFromComponent(ob, [e.latlng.lat, e.latlng.lng]))); - } - }); - }); + button.addEventListener('click', this.#addObservableWithClick.bind(this)); return button; } @@ -384,7 +345,7 @@ class FeatureEditModal { // implements ObserverInterface, ModalInterface button.button.getElementsByClassName('icon')[0].classList.add('-white'); button = button.button; button.addEventListener('click', () => { - this.#controller.deleteObservables(this.#observables); + this.#controller.deleteObservables([this.#observable]); }); return button; } @@ -403,7 +364,7 @@ class FeatureEditModal { // implements ObserverInterface, ModalInterface button = button.button; const that = this; button.addEventListener('click', function(e) { - that.#controller.deleteProperty(that.#observables, propertyKey); + that.#controller.deleteProperty([that.#observable], propertyKey); // - This is code is required for a transitionalDeleteButton to remove the row let parentElement = this.parentElement; while (!(parentElement instanceof HTMLTableRowElement)) { @@ -415,153 +376,190 @@ class FeatureEditModal { // implements ObserverInterface, ModalInterface return button; } - /** - * - Iterate through all of the observables and map each property key to all unique values for that (treeProps) property key across all of the - * observables. Also includes treeKey, longitude, and latitude values - * @returns {Object} - */ - #getKeyToValuesMapping() { - const keyToValues = { - meta: {}, - treeProps: {}, - coordinates: {} - }; - this.#observables.forEach(ob => { - const treeKey = ob.getProperty('treeKey', 'meta'); - if (!keyToValues.meta.hasOwnProperty('treeKey')) { - keyToValues.meta.treeKey = [treeKey]; - } else if (!keyToValues.meta.treeKey.includes(treeKey)) { - keyToValues.meta.treeKey.push(treeKey); - } - if (ob.hasProperty('treeProps', 'meta')) { - for (const [k, v] of Object.entries(ob.getProperties('treeProps'))) { - if (!keyToValues.treeProps.hasOwnProperty(k)) { - keyToValues.treeProps[k] = [v]; - } else if (!keyToValues.treeProps[k].includes(v)) { - keyToValues.treeProps[k].push(v); - } - } - } - let coordinatesArray = []; - if (ob.isNode()) { - const [lon, lat] = ob.getCoordinates(); - coordinatesArray = [['longitude', +lon], ['latitude', +lat]]; - } - if (ob.isLine()) { - const [[lon_1, lat_1], [lon_2, lat_2]] = ob.getCoordinates(); - coordinatesArray = [['longitude', +lon_1], ['latitude', +lat_1], ['longitude', +lon_2], ['latitude', +lat_2]]; - } - coordinatesArray.forEach(ary => { - const k = ary[0]; - const v = ary[1]; - if (!keyToValues.coordinates.hasOwnProperty(k)) { - keyToValues.coordinates[k] = [v]; - } else if (!keyToValues.coordinates[k].includes(v)) { - keyToValues.coordinates[k].push(v); - } - }); - }); - return keyToValues; - } - /** * - TODO: move this into the controller? Wouldn't be so bad if I did. Actually I should because of mass add. But mass add should just be another * button so this function can stay here. * @param {Feature} component - a component feature * @param {Array} [coordinates=null] - an array of coordinates in [, ] format that the new feature should have instead of the - * coordinates that were in the component + * coordinates that were in the component + * @throws an error if it isn't possible to get a valid feature from a component * @returns {Feature} a feature that can be added to the graph */ #getObservableFromComponent(component, coordinates=null) { if (!(component instanceof Feature)) { - throw TypeError('"component" argument must be instanceof Feature.'); + throw TypeError('The "component" argument must be instanceof Feature.'); } + // 1) Clone the component to create an observable and correct errors that are possible to correct const geometry = { - type: 'Point' + type: component.getCoordinates()[0] instanceof Array ? 'LineString' : 'Point', + // - Node components come with [0, 0] coordinates from the back-end, line components come with [[0, 0], [0, 0]] coordinates from the + // back-end and configuration object components come with [null, null] coordinates from the back-end. This is important for the + // isNode(), isLine(), and isConfigurationObject() functions + coordinates: structuredClone(component.getCoordinates()) };; - const observable = new Feature({ + const feature = new Feature({ geometry: geometry, properties: { + // - Components come with a treeKey in the from of "component:" from the back-end. A cloned component feature is only given a + // valid treeKey (and a valid name) by a FeatureController when it is inserted into the graph because that's the only way to ensure + // that the next max number is being used treeKey: component.getProperty('treeKey', 'meta'), treeProps: structuredClone(component.getProperties('treeProps')) }, type: 'Feature' }); - // - Start with whatever coordinates were in the text inputs - let featureCoordinates = structuredClone(component.getCoordinates()); - // - If coordinates were provided, use those instead - if (coordinates !== null) { - featureCoordinates = [coordinates[1], coordinates[0]]; - } - // - If the component is a line, get the coordinates of its nodes - if (component.isLine()) { - geometry.type = 'LineString'; - const fromKey = this.#controller.observableGraph.getKeyForComponent(observable.getProperty('from')); - const toKey = this.#controller.observableGraph.getKeyForComponent(observable.getProperty('to')); - const { sourceLat, sourceLon, targetLat, targetLon } = this.#controller.observableGraph.getLineLatLon(fromKey, toKey); - featureCoordinates = [[sourceLon, sourceLat], [targetLon, targetLat]]; - } - geometry.coordinates = featureCoordinates; - return observable; + // - If the feature is a line, get the coordinates of its nodes + if (feature.isLine()) { + // - If the feature has valid "from" and "to" properties, add the line between those nodes + const fromValidity = this.#controller.observableGraph.getLineConnectionNameValidity(feature, feature.getProperty('from')); + const toValidity = this.#controller.observableGraph.getLineConnectionNameValidity(feature, feature.getProperty('to')); + const fromToValidity = new Validity(false); + let fromKey; + let toKey; + if (fromValidity.isValid && toValidity.isValid) { + fromKey = this.#controller.observableGraph.getKeyForComponent(feature.getProperty('from')); + toKey = this.#controller.observableGraph.getKeyForComponent(feature.getProperty('to')); + if (fromKey !== toKey) { + fromToValidity.isValid = true; + } else { + throw Error('The line was not added because lines may not start and end at the same node.'); + } + } + if (fromToValidity.isValid) { + const { sourceLat, sourceLon, targetLat, targetLon } = this.#controller.observableGraph.getLineLatLon(fromKey, toKey); + geometry.coordinates = [[sourceLon, sourceLat], [targetLon, targetLat]]; + // - If the feature does not have valid "from" and "to" properties, add the line to the nodes that are closest to the coordinates + } else { + // - Lines can be connected to other lines, but I don't automatically do that + // - Lines can be connect to child nodes, but I don't automatically do that + const possibleNodes = this.#controller.observableGraph.getObservables(ob => ob.isNode() && !ob.hasProperty('parent')); + if (possibleNodes.length < 2) { + throw Error('The line was not added because there are no valid nodes that this line could connect to.'); + } + let [fromLon, fromLat] = possibleNodes[0].getCoordinates(); + let fromNodeDistanceDiff = Math.abs(coordinates[1] - fromLon) + Math.abs(coordinates[0] - fromLat); + let fromName = possibleNodes[0].getProperty('name'); + let [toLon, toLat] = possibleNodes[1].getCoordinates(); + let toNodeDistanceDiff = Math.abs(coordinates[1] - toLon) + Math.abs(coordinates[0] - toLat); + let toName = possibleNodes[1].getProperty('name'); + for (let i = 2; i < possibleNodes.length; i++) { + const [lon, lat] = possibleNodes[i].getCoordinates(); + const distanceDiff = Math.abs(coordinates[1] - lon) + Math.abs(coordinates[0] - lat); + if (distanceDiff < fromNodeDistanceDiff) { + if (fromNodeDistanceDiff > toNodeDistanceDiff) { + fromLon = lon; + fromLat = lat; + fromNodeDistanceDiff = distanceDiff; + fromName = possibleNodes[i].getProperty('name'); + continue; + } else { + toLon = lon; + toLat = lat; + toNodeDistanceDiff = distanceDiff; + toName = possibleNodes[i].getProperty('name'); + continue; + } + } + if (distanceDiff < toNodeDistanceDiff) { + if (toNodeDistanceDiff > fromNodeDistanceDiff) { + toLon = lon; + toLat = lat; + toNodeDistanceDiff = distanceDiff; + toName = possibleNodes[i].getProperty('name'); + continue; + } else { + fromLon = lon; + fromLat = lat; + fromNodeDistanceDiff = distanceDiff; + fromName = possibleNodes[i].getProperty('name'); + } + } + } + feature.setProperty('from', fromName); + feature.setProperty('to', toName); + geometry.coordinates = [[fromLon, fromLat], [toLon, toLat]]; + } + } else if (feature.isNode()) { + // - If the feature is a node, use the coordinates from the map click + if (coordinates !== null) { + geometry.coordinates = [coordinates[1], coordinates[0]]; + } else { + throw Error('Adding a node requires coordinates from a map click.'); + } + if (feature.isChild()) { + const validity = this.#controller.observableGraph.getLineConnectionNameValidity(feature, feature.getProperty('parent')); + if (!validity.isValid) { + const possibleParents = this.#controller.observableGraph.getObservables(ob => ob.isNode() && !ob.hasProperty('parent')); + if (possibleParents.length < 1) { + throw Error('The child was not added because there were no valid parents that this child could connect to.'); + } + let [parentLon, parentLat] = possibleParents[0].getCoordinates(); + let parentDistanceDiff = Math.abs(coordinates[1] - parentLon) + Math.abs(coordinates[0] - parentLat); + let parentName = possibleParents[0].getProperty('name'); + for (const parent of possibleParents) { + const [lon, lat] = parent.getCoordinates(); + const distanceDiff = Math.abs(coordinates[1] - lon) + Math.abs(coordinates[0] - lat); + if (distanceDiff < parentDistanceDiff) { + parentDistanceDiff = distanceDiff; + parentName = parent.getProperty('name'); + } + } + feature.setProperty('parent', parentName); + } + } + } + // 2) Detect any invalid property values that I cannot fix and throw an exception + if (feature.hasProperty('type') && feature.getProperty('type').toLowerCase() === 'parentchild') { + throw Error('The "type" property may not have a value of "parentChild".'); + } + if (feature.hasProperty('treeKey')) { + throw Error('An object may not have the property "treeKey".'); + } + return feature; } /** * - Return a text input that can be viewed in a modal * @param {string} propertyKey - * @param {Array} [propertyValues=null] - * @returns {HTMLInputElement} a text input that can be edited on to change a property value in an ObservableInterface instance + * @param {} [propertyValue=null] + * @returns {HTMLInputElement} a text input that can be edited to change a property value in an ObservableInterface instance */ - #getValueTextInput(propertyKey, propertyValues=null) { + #getValueTextInput(propertyKey, propertyValue=null) { if (typeof propertyKey !== 'string') { - throw TypeError('"propertyKey" argument must be typeof string.'); - } - if (!(propertyValues instanceof Array) && propertyValues !== null) { - throw TypeError('"propertyValues" argument must be instanceof Array or null.'); + throw TypeError('The "propertyKey" argument must be typeof string.'); } const input = document.createElement('input'); - input.addEventListener('mousedown', (e) => { - e.stopPropagation(e); - }); - if (propertyValues === null) { + if (propertyValue === null) { //- Do nothing. A new property was just added so the value text input should be blank - } else if (propertyValues.length === 1) { - // - This works even if propertyValues = [""], which it can be sometimes - input.value = propertyValues[0]; } else { - //input.value = ``; - input.value = propertyValues.join(', '); + input.value = propertyValue.toString(); } let originalValue = input.value; const that = this; input.addEventListener('change', function() { const inputValue = this.value.trim(); - if (that.#valueTextInputIsValid(propertyKey, inputValue)) { - that.#observables.forEach(ob => { + if (that.#observable.isComponentFeature()) { + // - Don't perform validation. Since latitude and longitude are no longer displayed in component tables, there's no way for the user + // to fail validation and trigger an error + that.#controller.setProperty([that.#observable], propertyKey, inputValue); + } else { + if (that.#valueTextInputIsValid(propertyKey, inputValue)) { if (['latitude', 'longitude'].includes(propertyKey)) { - if (ob.isNode()) { - const [lon, lat] = ob.getCoordinates(); - if (propertyKey === 'latitude') { - that.#controller.setCoordinates([ob], [lon, inputValue]); - } else { - that.#controller.setCoordinates([ob], [inputValue, lat]); - } - } else if (ob.isLine()) { - const [[lon, lat], [lon_1, lat_1]] = ob.getCoordinates(); + if (that.#observable.isNode()) { + const [lon, lat] = that.#observable.getCoordinates(); if (propertyKey === 'latitude') { - that.#controller.setCoordinates([ob], [[lon, inputValue], [lon_1, inputValue]]); + that.#controller.setCoordinates([that.#observable], [lon, inputValue]); } else { - that.#controller.setCoordinates([ob], [[inputValue, lat], [inputValue, lat_1]]); + that.#controller.setCoordinates([that.#observable], [inputValue, lat]); } } - } else if (['from', 'to', 'parent'].includes(propertyKey)) { - that.#controller.setProperty([ob], propertyKey, inputValue); } else { - that.#controller.setProperty([ob], propertyKey, inputValue); + that.#controller.setProperty([that.#observable], propertyKey, inputValue); } - }); - originalValue = inputValue; - } else { - this.value = originalValue; + originalValue = inputValue; + } else { + this.value = originalValue; + } } }); return input; @@ -575,7 +573,7 @@ class FeatureEditModal { // implements ObserverInterface, ModalInterface button.button.classList.add('-blue'); button.button.getElementsByClassName('icon')[0].classList.add('-white'); button = button.button; - button.addEventListener('click', zoom.bind(null, this.#observables)); + button.addEventListener('click', zoom.bind(null, [this.#observable])); return button; } @@ -592,7 +590,7 @@ class FeatureEditModal { // implements ObserverInterface, ModalInterface deletePlaceholder.replaceWith(transitionalDeleteButton); const that = this; let originalValue = input.value; - input.addEventListener('change', function () { + input.addEventListener('change', function() { const inputValue = this.value.trim(); // - If the input value isn't valid, just don't create a text input for the value and don't update the feature's properties if (that.#keyTextInputIsValid(inputValue)) { @@ -601,7 +599,7 @@ class FeatureEditModal { // implements ObserverInterface, ModalInterface parentElement = parentElement.parentElement; } parentElement.remove(); - that.#controller.setProperty(that.#observables, inputValue, '', 'treeProps'); + that.#controller.setProperty([that.#observable], inputValue, '', 'treeProps'); } else { input.value = originalValue; } @@ -619,12 +617,8 @@ class FeatureEditModal { // implements ObserverInterface, ModalInterface return false; } else if (inputValue === '') { return false; - } else if (this.#observables.some(ob => ob.hasProperty(inputValue))) { - if (this.#observables.length === 1) { - alert(`The property "${inputValue}" could not be added because this object already has this property.`); - } else { - alert(`The property "${inputValue}" could not be added because one or more objects already has this property.`); - } + } else if (this.#observable.hasProperty(inputValue)) { + alert(`The property "${inputValue}" could not be added because this object already has this property.`); return false; } return true; @@ -632,99 +626,86 @@ class FeatureEditModal { // implements ObserverInterface, ModalInterface /** * - Render normal features + * @returns {undefined} */ - #renderOmfFeatures(propTable) { - const keyToValues = this.#getKeyToValuesMapping(); - for (const [key, ary] of Object.entries(keyToValues.meta)) { - const keySpan = document.createElement('span'); - keySpan.textContent = 'ID'; - keySpan.dataset.propertyKey = 'treeKey'; - keySpan.dataset.propertyNamespace = 'meta'; - if (ary.length === 1) { - propTable.insertTHeadRow({elements: [null, keySpan, ary[0].toString()], position: 'prepend'}) - } else { - propTable.insertTHeadRow({elements: [null, keySpan, ary.join(',')], position: 'prepend'}); - } - } - for (const [key, ary] of Object.entries(keyToValues.treeProps)) { - const keySpan = document.createElement('span'); - if (['object'].includes(key)) { - keySpan.textContent = key; - keySpan.dataset.propertyKey = key; + #renderOmfFeature(propTable) { + const sortedEntries = Object.entries(this.#observable.getProperties('treeProps')); + sortedEntries.sort((a, b) => a[0].localeCompare(b[0], 'en', {numeric: true})); + // - If the feature has the "object" property, add it to the top of the table + let i = 0; + while (i < sortedEntries.length) { + if (sortedEntries[i][0] === 'object') { + const keySpan = document.createElement('span'); + keySpan.dataset.propertyKey = 'object'; keySpan.dataset.propertyNamespace = 'treeProps'; - if (ary.length === 1) { - propTable.insertTHeadRow({elements: [null, keySpan, ary[0].toString()]}); - } else { - propTable.insertTHeadRow({elements: [null, keySpan, ary.join(', ')]}); - } - continue; - } - if (['from', 'to'].includes(key)) { - if (!this.#observables.every(ob => ob.isLine() && !ob.isParentChildLine())) { - continue; - } - } - if (key === 'type' && !this.#observables.every(ob => ob.isParentChildLine())) { - continue; - } - if (key === 'parent' && !this.#observables.every(ob => ob.isChild())) { + keySpan.textContent = 'object'; + propTable.insertTHeadRow({elements: [null, keySpan, this.#observable.getProperty('object').toString()], position: 'prepend'}) + break; + } + i++; + } + if (i < sortedEntries.length) { + sortedEntries.splice(i, 1); + } + // - Add the treeKey to the top of the table + const keySpan = document.createElement('span'); + keySpan.textContent = 'ID'; + keySpan.dataset.propertyKey = 'treeKey'; + keySpan.dataset.propertyNamespace = 'meta'; + propTable.insertTHeadRow({elements: [null, keySpan, this.#observable.getProperty('treeKey', 'meta').toString()], position: 'prepend'}) + // - Add the rest of the properties to the table + for (const [key, val] of sortedEntries) { + if (['from', 'to', 'type'].includes(key) && this.#observable.isParentChildLine()) { continue; } + const keySpan = document.createElement('span'); keySpan.textContent = key; keySpan.dataset.propertyKey = key; keySpan.dataset.propertyNamespace = 'treeProps'; let deleteButton = null; - if (!FeatureEditModal.#nonDeletableProperties.includes(key)) { + // - Users should never be able to delete properties from components + if (!FeatureEditModal.#nonDeletableProperties.includes(key) && !this.#observable.isComponentFeature()) { deleteButton = this.#getDeletePropertyButton(key); } - propTable.insertTBodyRow({elements: [deleteButton, keySpan, this.#getValueTextInput(key, ary)]}); + propTable.insertTBodyRow({elements: [deleteButton, keySpan, this.#getValueTextInput(key, val)]}); } - // - We don't allow the coordinates of multiple objects to be changed with multiselect - if (this.#observables.length === 1) { - for (const [key, ary] of Object.entries(keyToValues.coordinates)) { - const keySpan = document.createElement('span'); - if (['latitude', 'longitude'].includes(key)) { - if (!this.#observables.every(ob => ob.isNode() && !ob.isConfigurationObject())) { - continue; - } else { - keySpan.textContent = key; - keySpan.dataset.propertyKey = key; - // - longitude and latitude aren't in any property namespace - propTable.insertTBodyRow({elements: [null, keySpan, this.#getValueTextInput(key, ary)], position: 'prepend'}); - } - } - } + if (this.#observable.isNode() && !this.#observable.isComponentFeature()) { + const [lon, lat] = this.#observable.getCoordinates(); + let keySpan = document.createElement('span'); + keySpan.textContent = 'longitude'; + keySpan.dataset.propertyKey = 'longitude'; + // - longitude and latitude aren't in any property namespace + propTable.insertTBodyRow({elements: [null, keySpan, this.#getValueTextInput('longitude', lon)], position: 'prepend'}); + keySpan = document.createElement('span'); + keySpan.textContent = 'latitude'; + keySpan.dataset.propertyKey = 'latitude'; + propTable.insertTBodyRow({elements: [null, keySpan, this.#getValueTextInput('latitude', lat)], position: 'prepend'}); } // - I need this div to applying consistent CSS styling const div = document.createElement('div'); - if (this.#observables.every(ob => !ob.isComponentFeature()) && this.#observables.every(ob => !ob.isConfigurationObject())) { + // - Add buttons for regular nodes and lines + if (!this.#observable.isComponentFeature() && !this.#observable.isConfigurationObject()) { const zoomButton = this.#getZoomButton(); const deleteButton = this.#getDeleteFeatureButton(); div.append(zoomButton); div.append(deleteButton); propTable.insertTBodyRow({elements: [this.#getAddPropertyButton(), null, div]}); - } else if (this.#observables.every(ob => !ob.isComponentFeature())) { + // - Add buttons for configuration objects + } else if (!this.#observable.isComponentFeature()) { div.append(this.#getDeleteFeatureButton()); propTable.insertTBodyRow({elements: [this.#getAddPropertyButton(), null, div]}); - // - Add buttons for components } else { - if (this.#observables.every(ob => ob.isConfigurationObject())) { + // - Add buttons for component configuration objects + if (this.#observable.isConfigurationObject()) { div.append(this.#getAddConfigurationObjectButton()); propTable.insertTBodyRow({elements: [div], colspans: [3]}); - } else if (this.#observables.some(ob => ob.isConfigurationObject())) { - // - Don't add buttons. Configuration objects cannot be added because not every observable is a configuration object. - // Non-configuration objects cannot be added because there is at least one configuration object. The user should refine their search - // results, but mixing configuration and non-configuration objects isn't necessarily an error - } else { - if (this.#observables.every(ob => ob.isNode())) { - div.append(this.#getAddNodeWithMapClickButton()); - propTable.insertTBodyRow({elements: [div], colspans: [3]}); - } else if (this.#observables.every(ob => ob.isLine())) { - div.append(this.#getAddLineWithFromToButton()) - propTable.insertTBodyRow({elements: [div], colspans: [3]}); - } else { - // - Don't add buttons. The user's search returned both nodes and lines - } + // - Add buttons for node configuration objects + } else if (this.#observable.isNode()) { + div.append(this.#getAddNodeButton()); + propTable.insertTBodyRow({elements: [div], colspans: [3]}); + } else if (this.#observable.isLine()) { + div.append(this.#getAddLineButton()) + propTable.insertTBodyRow({elements: [div], colspans: [3]}); } } } @@ -732,8 +713,8 @@ class FeatureEditModal { // implements ObserverInterface, ModalInterface /** * - Render arbitrary features */ - #renderArbitraryFeatures(propTable) { - for (const [key, val] of Object.entries(this.#observables[0].getProperties('meta'))) { + #renderArbitraryFeature(propTable) { + for (const [key, val] of Object.entries(this.#observable.getProperties('meta'))) { if (key === 'TRACT') { propTable.insertTHeadRow({elements: [null, null, 'Census Tract', val.toString()], position: 'prepend'}); } else if (key === 'SOVI_SCORE') { @@ -747,61 +728,20 @@ class FeatureEditModal { // implements ObserverInterface, ModalInterface } /** - * @param {string} propertyKey - * @param {string} inputValue - * @returns {boolean} - */ - #toFromParentObjectIsValid(propertyKey, inputValue) { - let observableKey; - try { - if (this.#observables.every(ob => ob.isComponentFeature())) { - observableKey = this.#controller.observableGraph.getKeyForComponent(inputValue); - } else if (this.#observables.every(ob => !ob.isComponentFeature())) { - observableKey = this.#controller.observableGraph.getKey(inputValue, this.#observables[0].getProperty('treeKey', 'meta')); - // - This is commented out because it's fine if different objects in the search selection will return different keys. If there are - // different keys for the same name, the FeatureGraph should just return the correct key for each object. Actually it's not. What if a - // configuration object and a non-configuration object share a name? I could write logic that decides whether returning multiple keys is - // okay (e.g. do both keys point to non-configuration objects?) but that would be annoying. Just return false if there are multiple keys - if (this.#observables.some(ob => this.#controller.observableGraph.getKey(inputValue, ob.getProperty('treeKey', 'meta')) !== observableKey)) { - alert(`The value of the "${propertyKey}" property cannot be set to "${inputValue}" because multiple objects have that value for their - "name" property. Either ensure that value for the "${propertyKey}" property is a unique name, or change the value of the - "name" property of other object(s) to ensure the name is unique.`); - return false; - } - } else { - throw Error('Components and non-components should never be together in this.#observables'); - } - } catch { - alert(`No object has the value "${inputValue}" for the "name" property. Ensure that the value for the "${propertyKey}" property matches an existing name.`); - return false; - } - if (observableKey.startsWith('parentChild:')) { - alert(`The value "${inputValue}" is the name of a parent-child line. Parent-child line names cannot be used as a value for the "${propertyKey}" property.`); - return false; - } - const observable = this.#controller.observableGraph.getObservable(observableKey); - if (observable.isConfigurationObject()) { - alert(`The value "${inputValue}" is the name of a configuration object. Configuration object names cannot be used as a value for the "${propertyKey}" property.`); - return false; - } - // - Components are not in the graph, so the observable cannot be a component feature - return true; - } - - /** - * - Validate the just-inputed value for a value text input + * - Validate the just-inputed value for a value text input for an EXISTING feature * @param {string} propertyKey * @param {string} inputValue * @returns {boolean} whether the propertyKey and value are valid from a domain perspective */ #valueTextInputIsValid(propertyKey, inputValue) { if (typeof propertyKey !== 'string') { - throw TypeError('"propertyKey" argument must be typeof string.'); + throw TypeError('The "propertyKey" argument must be typeof string.'); } if (typeof inputValue !== 'string') { - throw TypeError('"inputValue" argument must be typeof string.'); + throw TypeError('The "inputValue" argument must be typeof string.'); } // - I am no longer always converting to lowercase because names are case-sensitive and therefore other properties should be too + let validity; switch (propertyKey) { case 'type': inputValue = inputValue.toLowerCase(); @@ -810,75 +750,41 @@ class FeatureEditModal { // implements ObserverInterface, ModalInterface return false; } return true; - case 'treeKey': - alert('The "treeKey" property cannot be changed.'); - return false; case 'name': - if (this.#observables.length > 1) { - alert('The "name" property cannot be edited for multiple objects simultaneously.'); + if (inputValue.trim() === '') { + alert('The "name" property cannot be blank.'); + return false; + } + if (this.#controller.observableGraph.getObservables(ob => ob.hasProperty('name') && ob.getProperty('name') === inputValue).length > 0) { + alert(`The "name" property must be unique for all objects. The name "${inputValue}" is already used by another object.`); return false; - } else { - if (inputValue.trim() === '') { - alert('The "name" property cannot be blank.'); - return false; - } - if (this.#controller.observableGraph.getObservables(ob => ob.hasProperty('name') && ob.getProperty('name') === inputValue).length > 0) { - alert(`The "name" property must be unique for all objects. The name "${inputValue}" is already used by another object.`); - return false; - } } return true; case 'from': case 'to': - // - Test. It works - //[...this.#modal.divElement.getElementsByTagName('tr')].forEach(tr => { - // [...tr.getElementsByTagName('span')].forEach(span => { - // if (span.textContent === propertyKey) { - // tr.getElementsByTagName('input')[0].classList.add('invalid'); - // } - // }); - //}); - // - Test - if (!this.#observables.every(ob => ob.isLine())) { - alert(`The value of the "${propertyKey}" property cannot be edited for non-line objects. Ensure your search includes only line objects.`); - return false; - } - if (!this.#toFromParentObjectIsValid(propertyKey, inputValue)) { + validity = this.#controller.observableGraph.getLineConnectionNameValidity(this.#observable, inputValue); + if (!validity.isValid) { + alert(validity.reason); return false; } - if (propertyKey === 'from' && this.#observables.some(ob => ob.getProperty('to') === inputValue)) { - if (this.#observables.length === 1) { - alert(`This line already has the value "${inputValue}" for the property "to". Lines may not begin and end on the same object.`); - } else { - alert(`One or more objects already has the value "${inputValue}" for the property "to". Lines may not begin and end on the same object.`); - } + if (propertyKey === 'from' && this.#observable.getProperty('to') === inputValue) { + alert(`This line already has the value "${inputValue}" for the property "to". Lines may not begin and end on the same object.`); return false; } - if (propertyKey === 'to' && this.#observables.some(ob => ob.getProperty('from') === inputValue)) { - if (this.#observables.length === 1) { - alert(`This line already has the value "${inputValue}" for the property "from". Lines may not begin and end on the same object.`); - } else { - alert(`One or more objects already has the value "${inputValue}" for the property "from". Lines may not begin and end on the same object.`); - } + if (propertyKey === 'to' && this.#observable.getProperty('from') === inputValue) { + alert(`This line already has the value "${inputValue}" for the property "from". Lines may not begin and end on the same object.`); return false; } return true; case 'parent': - if (!this.#observables.every(ob => ob.isChild())) { - alert(`The value of the "${propertyKey}" property cannot be edited for non-child objects. Ensure your search includes only child objects.`); + validity = this.#controller.observableGraph.getLineConnectionNameValidity(this.#observable, inputValue); + if (!validity.isValid) { + alert(validity.reason); return false; } - return this.#toFromParentObjectIsValid(propertyKey, inputValue); + return true; case 'latitude': case 'longitude': - if (this.#observables.some(ob => ob.isConfigurationObject())) { - if (this.#observables.length === 1) { - alert(`This object is a configuration object. The property "${propertyKey}" cannot be set for configuration objects because they aren't shown on the map.`); - } else { - alert(`One or more objects are configuration objects. The property "${propertyKey}" cannot be set for configuration objects because they aren't shown on the map.`); - } - return false; - } if (inputValue === '' || isNaN(+inputValue)) { alert(`The value "${inputValue}" is not a valid number. A value for the "${propertyKey}" property must be a valid number.`); return false; @@ -897,7 +803,7 @@ class FeatureEditModal { // implements ObserverInterface, ModalInterface */ function zoom(observables) { if (!(observables instanceof Array)) { - throw TypeError('"observables" argument must be instanceof Array.'); + throw TypeError('The "observables" argument must be instanceof Array.'); } if (observables.length === 1) { const observable = observables[0]; diff --git a/omf/static/geoJsonMap/v3/featureGraph.js b/omf/static/geoJsonMap/v3/featureGraph.js index d04d48c29..64e397377 100644 --- a/omf/static/geoJsonMap/v3/featureGraph.js +++ b/omf/static/geoJsonMap/v3/featureGraph.js @@ -1,7 +1,9 @@ export { FeatureGraph, FeatureNotFoundError }; import { Feature, ObserverError, UnsupportedOperationError } from './feature.js'; +import { Validity } from '../v4/mvc/models/validity/validity.js'; class FeatureGraph { + #graph; // - This points to the actual graph data structure. I'm using the Graphology library #keyToFeature; // - Graphology graphs allow nodes and lines to have the same key. This is great for representing lines with children, but is annoying when trying to retrieve a feature by its key #nameToKey; // - Names are not unique, so I potentially need to map one name to multiple keys @@ -388,14 +390,14 @@ class FeatureGraph { * @param {string} name - the name of the feature that I want to retrieve the key for * @param {string} featureKey - the key of an instance of my Feature class that is requesting the key * @returns {string} The correct tree key of the feature with the given name, depending on which object requested it, else throw an exception if - * there is no matching tree key + * there is no matching tree key */ getKey(name, featureKey) { if (typeof name !== 'string') { throw TypeError('The "name" argument must be typeof string.'); } if (typeof featureKey !== 'string') { - throw TypeError('The "feature" argument must be typeof string.'); + throw TypeError('The "featureKey" argument must be typeof string.'); } const feature = this.getObservable(featureKey); const keys = this.#nameToKey[name]; @@ -510,7 +512,7 @@ class FeatureGraph { throw ReferenceError(`The key namespace "${namespace}" does not exist in this FeatureGraph. Leave the "namespace" argument empty to use the "default" key namespace.`); } } - // Math.max.apply(null, []) === -Infinity, so I start with 0 + // - Math.max.apply(null, []) === -Infinity, so I start with 0 if (keys.length === 0) { keys = [0]; } @@ -656,6 +658,45 @@ class FeatureGraph { }); } + /** + * - Return whether a name value for the "to", "from", or "parent" property is valid as a connection point for a line + * @param {Feature} observable - an observable argument because I should use getKey() for regular features and getKeyForComponent() for + * component features + * @param {string} name - the name of an object that the observable wants to connect to via the "to", "from", or "parent' property + * @returns {Validity} + */ + getLineConnectionNameValidity(observable, name) { + let toFromParentKey; + const validity = new Validity(true); + try { + if (observable.isComponentFeature()) { + toFromParentKey = this.getKeyForComponent(name); + } else { + toFromParentKey = this.getKey(name, observable.getProperty('treeKey', 'meta')); + } + } catch (e) { + validity.isValid = false; + //validity.reason = e.message; + validity.reason = `No object has the value "${name}" for the "name" property. Ensure that the value for the "to", "from", or "parent" property matches an existing name.` + return validity; + } + if (observable.getProperty('treeKey', 'meta') === toFromParentKey) { + validity.isValid = false; + validity.reason = `An object cannot be a "to", "from", or "parent" of itself.`; + } + if (toFromParentKey.startsWith('parentChild:')) { + validity.isValid = false; + validity.reason = `The value "${name}" is the name of a parent-child line. Parent-child line names cannot be used as a value for the "to", "from", or "parent" properties.`; + return validity; + } + if (this.getObservable(toFromParentKey).isConfigurationObject()) { + validity.isValid = false; + validity.reason = `The value "${name}" is the name of a configuration object. Configuration object names cannot be used as a value for the "to", "from", or "parent" properties.`; + return validity; + } + return validity; + } + // ********************* // ** Private methods ** // ********************* @@ -684,6 +725,9 @@ class FeatureGraph { * */ #insertObservableIntoKeyToFeature(observable) { + if (!observable.hasProperty('treeKey', 'meta')) { + throw Error('The observable does not have the "treeKey" property.'); + } const observableKey = observable.getProperty('treeKey', 'meta'); if (this.#keyToFeature.hasOwnProperty(observableKey)) { throw Error(`The key "${observableKey}" already exists in this.#keyToFeature.`); @@ -772,7 +816,8 @@ class FeatureNameNotFoundError extends Error { constructor(name) { super(); - this.message = `This FeatureGraph instance could not find a key for the object named "${name}".`; + //this.message = `This FeatureGraph instance could not find a key for the object named "${name}".`; + this.message = `No object was found with the name "${name}".`; this.name = 'FeatureNameNotFoundError'; } } diff --git a/omf/static/geoJsonMap/v3/leafletLayer.js b/omf/static/geoJsonMap/v3/leafletLayer.js index b3353959c..dd473a43b 100644 --- a/omf/static/geoJsonMap/v3/leafletLayer.js +++ b/omf/static/geoJsonMap/v3/leafletLayer.js @@ -175,7 +175,7 @@ class LeafletLayer { // implements ObserverInterface const layer = Object.values(this.#layer._layers)[0]; layer.bindPopup( () => { - this.#modal = new FeatureEditModal([this.#observable], this.#controller); + this.#modal = new FeatureEditModal(this.#observable, this.#controller); return this.#modal.getDOMElement(); }, // - I CANNOT set this option because then the popup won't follow a node around when it is dragged! diff --git a/omf/static/geoJsonMap/v3/searchModal.js b/omf/static/geoJsonMap/v3/searchModal.js index 859d96839..8ca167cf1 100644 --- a/omf/static/geoJsonMap/v3/searchModal.js +++ b/omf/static/geoJsonMap/v3/searchModal.js @@ -506,7 +506,7 @@ class SearchModal { * @returns {HTMLButtonElement} */ #getResetButton() { - let button = new IconLabelButton({paths: IconLabelButton.getCircularArrowPaths(), viewBox: '-4 -4 24 24', text: 'Clear', tooltip: 'Clear the search criteria'}); + let button = new IconLabelButton({paths: IconLabelButton.getCircularArrowPaths(), viewBox: '-4 -4 24 24', text: 'Clear', tooltip: 'Clear the search criteria. Also unloads search results which makes the interface faster.'}); button.button.classList.add('-blue'); button.button.getElementsByClassName('icon')[0].classList.add('-white'); button.button.getElementsByClassName('label')[0].classList.add('-white'); diff --git a/omf/static/geoJsonMap/v4/mvc/models/validity/validity.js b/omf/static/geoJsonMap/v4/mvc/models/validity/validity.js new file mode 100644 index 000000000..aa8099685 --- /dev/null +++ b/omf/static/geoJsonMap/v4/mvc/models/validity/validity.js @@ -0,0 +1,48 @@ +export { Validity }; + +/** + * - Create a custom Boolean object that has a boolean state and a reason for why that state is true or false. This class is an attempt to stop + * creating methods that return an object while also having the ability to throw a custom exception. Exceptions and try-catch blocks should be used + * for exceptional circumstances. Regular validation logic is not an exceptional circumstance. Using this class allows me to chain together various + * if-statements inside of a function and understand which of the if-statements failed the validation + * - v3 code has many functions that retrieve a value while also having the ability to throw a custom exception and I would like to get rid of those + * functions eventually + */ +class Validity { + + #isValid; + #reason; + + constructor(isValid, reason='') { + if (typeof isValid !== 'boolean') { + throw TypeError('The "isValid" argument must be typeof "boolean".'); + } + if (typeof reason !== 'string') { + throw TypeError('The "reason" argument must be typeof "string".'); + } + this.#isValid = isValid; + this.#reason = reason; + } + + get isValid() { + return this.#isValid; + } + + set isValid(isValid) { + if (typeof isValid !== 'boolean') { + throw TypeError('The "isValid" argument must be typeof "boolean".'); + } + this.#isValid = isValid; + } + + get reason() { + return this.#reason; + } + + set reason(reason) { + if (typeof reason !== 'string') { + throw TypeError('The "reason" argument must be typeof "string".'); + } + this.#reason = reason; + } +} \ No newline at end of file diff --git a/omf/static/geoJsonMap/v4/mvc/models/validity/validity.test.js b/omf/static/geoJsonMap/v4/mvc/models/validity/validity.test.js new file mode 100644 index 000000000..760eb771c --- /dev/null +++ b/omf/static/geoJsonMap/v4/mvc/models/validity/validity.test.js @@ -0,0 +1,91 @@ +import { describe, expect, it, vi } from 'vitest'; +import { Validity } from './validity.js'; + +/** + * - Remember + * - If a function has no control logic (e.g. getter functions), then I shouldn't test that function + */ + +describe('Validity', () => { + + describe('given an isValid argument that is not a boolean', () => { + + it('throws an exception', () => { + expect(() => new Validity('foobar')).toThrow(/must be typeof "boolean"/); + }); + }); + + describe('given an isValid argument that is true', () => { + + it('sets this.isValid to true', () => { + const validity = new Validity(true); + expect(validity.isValid).toBe(true); + }); + }); + + describe('given an isValid argument that is false', () => { + + it('sets this.isValid to false', () => { + const validity = new Validity(false); + expect(validity.isValid).toBe(false); + }); + }); + + + describe('given a reason argument that is not a string', () => { + + it('throws an exception', () => { + expect(() => new Validity(true, 5)).toThrow(/must be typeof "string"/); + }); + }); + + describe('given a reason argument that is a string', () => { + + it('sets this.reason to equal the string', () => { + const validity = new Validity(false, 'Number out of range.'); + expect(validity.reason).toEqual('Number out of range.'); + }); + }); + + + describe('set isValid', () => { + + describe('given a true boolean argument', () => { + + it('sets this.#isValid to true', () => { + const validity = new Validity(false); + validity.isValid = true; + expect(validity.isValid).toBe(true); + }); + }); + + describe('given a false boolean argument', () => { + + it('sets this.#isValid to be false', () => { + const validity = new Validity(true); + validity.isValid = false; + expect(validity.isValid).toBe(false); + }); + }); + }); + + describe('set reason', () => { + + describe('given a reason argument that is not a string', () => { + + it('throws an exception', () => { + const validity = new Validity(true); + expect(() => validity.reason = 5).toThrow(/must be typeof "string"/); + }); + }); + + describe('given a reason argument that is a string', () => { + + it('sets this.reason to be the string', () => { + const validity = new Validity(false); + validity.reason = 'Number out of range.'; + expect(validity.reason).toEqual('Number out of range.'); + }); + }); + }); +}); \ No newline at end of file From ee1055f5a6891cc1fbf58c1b38ecc210103a5d65 Mon Sep 17 00:00:00 2001 From: ASC95 <16790624+ASC95@users.noreply.github.com> Date: Fri, 11 Oct 2024 13:34:16 -0400 Subject: [PATCH 2/2] change automatically generated new names from colon separator to underscore separator --- omf/static/geoJsonMap/v3/featureController.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/omf/static/geoJsonMap/v3/featureController.js b/omf/static/geoJsonMap/v3/featureController.js index ed5df6daa..d36659260 100644 --- a/omf/static/geoJsonMap/v3/featureController.js +++ b/omf/static/geoJsonMap/v3/featureController.js @@ -37,7 +37,7 @@ class FeatureController { // implements ControllerInterface let name = observable.getProperty('name'); let key = treeKey; while (this.observableGraph.getObservables(ob => ob.hasProperty('name') && ob.getProperty('name') === name).length > 0) { - name = `${name}:${key}`; + name = `${name}_${key}`; key += 1; } observable.setProperty('name', name);