diff --git a/src/map-app/app/map-ui.ts b/src/map-app/app/map-ui.ts index c902a8a1..ebf5b756 100644 --- a/src/map-app/app/map-ui.ts +++ b/src/map-app/app/map-ui.ts @@ -3,7 +3,7 @@ import { Map } from "./map"; import { MapPresenter } from "./presenter/map"; import { MarkerManager } from "./marker-manager"; import { Config } from "./model/config"; -import { DataServices } from "./model/data-services"; +import { DataServices, isVocabPropDef } from "./model/data-services"; import { EventBus } from "../eventbus"; import "./map"; // Seems to be needed to prod the leaflet CSS into loading. import { SidebarPresenter } from "./presenter/sidebar"; @@ -11,6 +11,7 @@ import { PhraseBook } from "../localisations"; import { toString as _toString } from '../utils'; import { Action, AppState, PropEquality, StateManager, TextSearch } from "./state-manager"; import { StateChange } from "../undo-stack"; +import { Dictionary } from "../common-types"; export class MapUI { public map?: Map; @@ -28,7 +29,12 @@ export class MapUI { this.labels = this.dataServices.getFunctionalLabels(); const allInitiatives = new Set(dataServices.getAggregatedData().loadedInitiatives); - const initialState = new AppState(allInitiatives, allInitiatives); + const initialFilters = this.mkInitialFilters(); + + const initialState = AppState.startState(allInitiatives, undefined, initialFilters); + + // Set the intial state when constructing the StateManager. This will be the state + // to which a reset returns to. this.stateManager = new StateManager(initialState, change => this.onStateChange(change)); // This is here to resolve a circular dependency loop - MapUI needs the SidebarView @@ -45,6 +51,35 @@ export class MapUI { EventBus.Directory.initiativeClicked.sub(initiative => this.onInitiativeClickedInSidebar(initiative)); } + // This inspects the config and constructs an appropriate set of + // filters to construct the initial AppState with. + static mkInitialFilters(config: Config): Dictionary { + const filters: Dictionary = {}; + const filteredFields = config.getFilteredPropDefs(); + for(const name in filteredFields) { + const propDef = filteredFields[name]; + const filter = propDef.filter; + if (filter != undefined) { + // If we get here, this property should have a default filter + // value set. + + // We can only filter Vocab properties (single or multi), so check that. + if (isVocabPropDef(propDef)) { + filters[name] = new PropEquality( + name, filter, propDef.type === 'multi' + ); + } + } + } + return filters; + } + + // This inspects the MapUI's config and constructs an appropriate + // set of filters to construct the initial AppState with. + private mkInitialFilters() { + return MapUI.mkInitialFilters(this.config); + } + createMap() { if (this.mapPresenter) return; diff --git a/src/map-app/app/model/config-schema.ts b/src/map-app/app/model/config-schema.ts index dd18e144..e2d7bf02 100644 --- a/src/map-app/app/model/config-schema.ts +++ b/src/map-app/app/model/config-schema.ts @@ -25,7 +25,7 @@ import type { DataServices, PropDef, PropDefs, - FieldDefs, + ConfigPropDefs, } from './data-services'; import type { @@ -110,7 +110,8 @@ export interface ReadableConfig { attr_namespace(): string; doesDirectoryHaveColours(): boolean; elem_id(): string; - fields(): FieldDefs; + fields(): ConfigPropDefs; // @deprecated + getPropDefs(): ConfigPropDefs; getCustomPopup(): InitiativeRenderFunction | undefined; getDataSources(): AnyDataSource[]; getDefaultLatLng(): Point2d; @@ -118,6 +119,7 @@ export interface ReadableConfig { getDialogueSize(): DialogueSize; getDisableClusteringAtZoom(): number; getFilterableFields(): string[]; + getFilteredPropDefs(): Record; getInitialBounds(): Box2d | undefined; getLanguage(): string; getLanguages(): string[]; @@ -209,7 +211,7 @@ export class ConfigData { disableClusteringAtZoom: number = 0; doesDirectoryHaveColours: boolean = false; elem_id: string = 'map-app'; - fields: Dictionary = {}; + fields?: Dictionary; // @deprecated - use propDefs going forwards filterableFields: string[] = []; gitcommit: string = '0'; htmlTitle: string = ''; @@ -226,6 +228,7 @@ export class ConfigData { minZoom: number = 2; noLodCache: boolean = true; mykoMapVersion: string = '0'; + propDefs: Dictionary = {}; searchedFields: string[] = ['name']; servicesPath: string = 'services/'; showAboutPanel: boolean = true; @@ -469,14 +472,14 @@ export class Config implements ReadableConfig, WritableConfig { private readonly data: ConfigData; private readonly configSchemas: ConfigSchemas; - private _fields: PropDefs; - - private stringsToPropDefs(fields: ConfigData['fields']): PropDefs { - const propDefEntries = Object.entries(fields ?? {}).map( - ([id, field]) => { - if (typeof field === 'string') - return [id, { type: field, from: id }]; - return [id, field]; + private _propDefs: PropDefs; + + private stringsToPropDefs(propDefs: ConfigData['propDefs']): PropDefs { + const propDefEntries = Object.entries(propDefs ?? {}).map( + ([id, def]) => { + if (typeof def === 'string') + return [id, { type: def, from: id }]; + return [id, def]; } ); return Object.fromEntries(propDefEntries); @@ -571,7 +574,8 @@ export class Config implements ReadableConfig, WritableConfig { }, fields: { id: 'fields', - descr: 'Defines extended definitions of new or existing initiative fields', + descr: 'If present, defines extended definitions of extended or existing initiative fields '+ + '(deprecated - use propDefs going forward)', getter: 'fields', type: types.propDefs, }, @@ -687,6 +691,12 @@ export class Config implements ReadableConfig, WritableConfig { getter: 'getVersionTag', type: types.string, }, + propDefs: { + id: 'propDefs', + descr: 'Defines extended definitions of extended or existing initiative properties', + getter: 'getPropDefs', + type: types.propDefs, + }, searchedFields: { id: 'searchedFields', descr: "A list of fields that are looked at when using the search function. Valid "+ @@ -771,7 +781,7 @@ export class Config implements ReadableConfig, WritableConfig { }, vocabularies: { id: 'vocabularies', - descr: 'Specifies the vocabularies to obtain via SPARQL query for use in `fields`', + descr: 'Specifies the vocabularies to obtain via SPARQL query for use in `propDefs`', defaultDescr: 'No vocabs are queried if nothing is provided', getter: 'vocabularies', type: types.vocabSources, @@ -784,9 +794,10 @@ export class Config implements ReadableConfig, WritableConfig { if (this.data.languages.length === 0) throw new Error("languages is configured empty, this should not happen"); this.data.languages = this.data.languages.map(validateLang); + this.validateFilterableFields(this.data.filterableFields); // Expand abbreviated field defs - this._fields = this.stringsToPropDefs(this.data.fields); + this._propDefs = this.stringsToPropDefs(this.data.fields ?? this.data.propDefs); // Special know-how validations... @@ -818,10 +829,10 @@ export class Config implements ReadableConfig, WritableConfig { } } - // Make sure the fields all reference a known vocab + // Make sure the propDefs all reference a known vocab /* FIXME this no longer works - can we check it later? - for(const fieldId in this._fields ?? {}) { - let field = this._fields[fieldId]; + for(const fieldId in this._propDefs ?? {}) { + let field = this._propDefs[fieldId]; if (field === undefined) continue; if (field.type === 'multi') @@ -873,12 +884,12 @@ is essentially an empty object, but that would result in a totally empty map. For the sake of illustration, here is an example of what you might put in this parameter for a map with pins which have a \`size\`, -\`description\` and \`address\` field, in addition of the hard-wired -bare minimum fields of \`uri\`, \`name\`, \`lat\` and \`lng\`. The +\`description\` and \`address\` properties, in addition of the hard-wired +bare minimum properties of \`uri\`, \`name\`, \`lat\` and \`lng\`. The \`size\` field can be one of several pre-defined values - a taxonomy, -also known as a vocabulary. Because of the \`filterableFields\` -attribute, there will be a single drop-down on the search panel for this -narrowing the displayed pins by values of this field. +also known as a vocabulary. Because of the presence of a \`filter\` +attribute of \`size\`, there will be a single drop-down on the search +panel for this narrowing the displayed pins by values of this field. \`\`\` import { ConfigData } from "mykomap/app/model/config-schema"; @@ -887,14 +898,14 @@ import { InitiativeObj } from "mykomap/src/map-app/app/model/initiative"; const config: ConfigData = { htmlTitle: "Outlets", - fields: { + propDefs: { // the old name for this is 'fields', but deprecated address: 'value', size: { type: 'vocab', uri: 'sz:', + filter: undefined, }, }, - filterableFields: ["size"], vocabularies: [ { type: 'json', @@ -927,7 +938,7 @@ This config would need to be accompanied with a \`example.json\` file defining the vocabs, and a data file \`example.csv\`. Both of these can be supplied in map project source code in the \`www/\` directory, or an URL to elsewhere can be supplied. The \`transform\` attribute defines -the mapping from CSV fields to map pin fields. +the mapping from CSV fields to map pin properties. The vocabs file might look like this, which defines one vocabulary: size, represented in the config by the abbreviated base URI \`sz:\`. The language @@ -1036,8 +1047,13 @@ ${def.descr} return this.data.elem_id; } + // @deprecated fields() { - return this._fields; + return this._propDefs; + } + + getPropDefs() { + return this._propDefs; } getDataSources(): AnyDataSource[] { @@ -1057,9 +1073,38 @@ ${def.descr} getDisableClusteringAtZoom(): number { return this.data.disableClusteringAtZoom; } - getFilterableFields(): string[] { + // @deprecated: use getFilteredFields going forward + getFilterableFields(): string[] { return this.data.filterableFields; } + // Gets a dictionary of filtered properties, with the same order as their definition. + // + // Returns a shortlist dictionary of properties which have a filter attribute present + // (even if that is `undefined` or `null`, which still implies there should be a filter, + // just not one set to anything in particular, or one which includes only empty values) + getFilteredPropDefs(): Record { + const propDefs = this.getPropDefs(); + const filterableFields = this.data.filterableFields; + const filteredFields: Record = {}; + if (filterableFields.length > 0) { + // Back-compatibility override case: use these properties as filters + for(const name of filterableFields) { + const propDef = propDefs[name]; + if (propDef) + filteredFields[name] = propDef; + } + } + else { + // Standard case: look for properties with a filter + for(const name in propDefs) { + const propDef = propDefs[name]; + if (propDef != null && 'filter' in propDef) { // note loose null match + filteredFields[name] = propDef; + } + } + } + return filteredFields; + } getInitialBounds(): Box2d | undefined { return this.data.initialBounds; } @@ -1145,9 +1190,25 @@ ${def.descr} setDisableClusteringAtZoom(val: number): void { this.data.disableClusteringAtZoom = val; } + // @deprecated: set the `filter` property in field property definitions instead setFilterableFields(val: string[]): void { + this.validateFilterableFields(val); this.data.filterableFields = val; } + validateFilterableFields(val: string[]): void { + // Check that all the filterable fields are property names - + // Something is wrong if not. + const propDefs = this.data.fields ?? this.data.propDefs; + const badFields = val + .filter(name => !propDefs[name]); + + if (badFields.length > 0) { + throw new Error( + `setFilterableFields() used with invalid property names: `+ + badFields.join(", ") + ); + } + } setHtmlTitle(val: string): void { this.data.htmlTitle = val; } diff --git a/src/map-app/app/model/data-aggregator.ts b/src/map-app/app/model/data-aggregator.ts index 32cd879f..5b5f0c44 100644 --- a/src/map-app/app/model/data-aggregator.ts +++ b/src/map-app/app/model/data-aggregator.ts @@ -61,7 +61,7 @@ export class DataAggregator extends AggregatedData implements DataConsumer vocabs.getVocab(uri, config.getLanguage()), labels), @@ -217,7 +217,7 @@ export class DataAggregator extends AggregatedData implements DataConsumer; // A convenience variation of PropDefs used in ConfigData -export type FieldDefs = Dictionary; +export type ConfigPropDefs = Dictionary; // A convenient composite PropDef variation which combines vocab and // multi property definitions. It is essentially either a VocabPropDef -// or a MultiPropDef with an added uri field - so the uri field is +// or a MultiPropDef with an added uri attribute - so the uri attribute is // always present at the top level. export type AnyVocabPropDef = VocabPropDef | ( MultiPropDef & { uri: string } ); @@ -168,15 +189,15 @@ export function sortedInsert(element: unknown, array: unknown[]) { // Implement the latitude/longitude fall-back logic for InitiativeObj. // -// Creates a function which interprets a field of an InitiativeObj +// Creates a function which interprets an attribute of an InitiativeObj // (the param in question) as a numeric latitude or longitude value -// (or undefined if this fails). This works for string field values +// (or undefined if this fails). This works for string attributes // as well as numeric ones. // // Additionally, if manLat *and* manLng are defined in the // InitiativeObj (and numeric or numeric strings, but not "0"), use -// the field named by overrideParam. This will typically be manLat -// or manLng, allowing these fields to override the original field, +// the attribute named by overrideParam. This will typically be manLat +// or manLng, allowing these attributes to override the original attribute, // whatever it is. function mkLocFromParamBuilder(from: string, overrideParam: string) { return (id: string, def: CustomPropDef, params: InitiativeObj) => { @@ -390,10 +411,10 @@ export class DataServicesImpl implements DataServices { this.functionalLabels = functionalLabels; { - const fields = this.config.fields(); - for(const fieldId in fields) { - const fieldDef = fields[fieldId]; - this.propertySchema[fieldId] = fieldDef; + const propDefs = this.config.getPropDefs(); + for(const id in propDefs) { + const propDef = propDefs[id]; + this.propertySchema[id] = propDef; } } @@ -470,16 +491,6 @@ export class DataServicesImpl implements DataServices { }); } - { - // Need to record all instances of any of the fields that are specified in the config - // Expects an array of strings which are initiative field names. - const filterableFields: string[] = this.config.getFilterableFields(); - if (typeof (filterableFields) !== 'object' || !(filterableFields instanceof Array)) - throw new Error(`invalid filterableFields config for 'filterableFields' - not an array`); - if (filterableFields.findIndex(e => typeof (e) !== 'string') >= 0) - throw new Error(`invalid filterableFields config for 'filterableFields' - contains non-strings`); - } - { // `languages`' codes are validated and normalised in the config initialisation, // not here, so they are available everywhere. We can be sure there is at least one, @@ -494,22 +505,6 @@ export class DataServicesImpl implements DataServices { const language = this.config.getLanguage(); console.info("using language", language); } - - { - // Check that all the filterable fields are property names - - // Something is wrong if not. - const badFields = this.config.getFilterableFields() - .filter(name => !this.propertySchema[name]); - - if (badFields.length > 0) { - throw new Error( - `Filterable fields config must only include `+ - `names of defined properties: ${badFields.join(", ")}` - ); - } - } - - } getAggregatedData(): AggregatedData { diff --git a/src/map-app/app/model/initiative.ts b/src/map-app/app/model/initiative.ts index f2231a41..4a9bbbc2 100644 --- a/src/map-app/app/model/initiative.ts +++ b/src/map-app/app/model/initiative.ts @@ -15,7 +15,7 @@ export const trivialParamBuilder: ParamBuilder = (id, params, _) => par /// This class represents an initiative, AKA a pin on the map. /// -/// It is somewhat dynamic - the number and type of fields is defined at runtime, +/// It is somewhat dynamic - the number and type of properties is defined at runtime, /// from a PropDefs structure, and the initialisation is defined by a ParamBuilder. /// /// Inherently, it's basically a dictionary of enumberable, read-only @@ -24,7 +24,7 @@ export const trivialParamBuilder: ParamBuilder = (id, params, _) => par /// initialilsed created. /// /// Additionally, it has a search index property `searchstr`, which is -/// a string constructed by normalising the values of selected fields, +/// a string constructed by normalising the values of selected properties, /// and an `__internal` property for associating arbitrary data with /// the instance. /// @@ -34,8 +34,8 @@ export class Initiative { /// Constructor constructor! /// /// This constructs an Initiative constructor from the given specification - /// in propDefs (which defines the fields) and paramBuilder (which defines - /// how to construct values for these fields). + /// in propDefs (which defines the properties) and paramBuilder (which defines + /// how to construct values for these properties). static mkFactory(propDefs: PropDefs, paramBuilder: ParamBuilder = trivialParamBuilder, searchIndexer?: (value: unknown, propName: string, propDef: PropDef) => string|undefined) { @@ -78,7 +78,7 @@ export class Initiative { return _toString(a[prop]).localeCompare(_toString(b[prop])); } - /// Searches initiatives for objects whose searchstr fields include the search text + /// Searches initiatives for objects whose searchstr properties include the search text /// /// @return a new list of initiatives static textSearch(text: string, initiatives: Initiative[]): Initiative[] { @@ -92,14 +92,14 @@ export class Initiative { ); } - // Sorts initiatives by the named field, if text + // Sorts initiatives by the named property, if text // // @return a list of matching initiatives. static textSort(initiatives: Initiative[], prop: string = 'name'): Initiative[] { return [...initiatives].sort((a, b) => Initiative.compare(a, b, prop)); } - // Filters initiatives by the named field + // Filters initiatives by the named property static filter(initiatives: Initiative[], prop: string, valueRequired: unknown) { return initiatives.filter( it => it[prop] == valueRequired diff --git a/src/map-app/app/model/property-indexer.ts b/src/map-app/app/model/property-indexer.ts index 448fba77..07143953 100644 --- a/src/map-app/app/model/property-indexer.ts +++ b/src/map-app/app/model/property-indexer.ts @@ -39,7 +39,7 @@ export class PropertyIndexer { const value = initiative[propName]; if (value == null) { // This initiative has no value for `propName`, so can't be indexed further. - console.warn(`Initiative has no value for filter field ${propName}: ${initiative.uri}`); + console.warn(`Initiative has no value for filtered property ${propName}: ${initiative.uri}`); return; } @@ -53,8 +53,8 @@ export class PropertyIndexer { }); } - /// Loop through the filteredFields and sort the data, then sort the - /// keys in order. Sorts only the propName fields, not the + /// Loop through the filtered properties and sort the data, then sort the + /// keys in order. Sorts only the propName properies, not the /// initiatives they hold. onComplete(): void { @@ -116,7 +116,7 @@ export class PropertyIndexer { } else { // Create the object that holds the registered values for the current - // field if it hasn't already been created + // property if it hasn't already been created const values: Dictionary = this.byPropThenValue[propName] = {}; values[valueKey] = [initiative]; } diff --git a/src/map-app/app/model/sparql-data-loader.ts b/src/map-app/app/model/sparql-data-loader.ts index 9e844c8b..c672a29b 100644 --- a/src/map-app/app/model/sparql-data-loader.ts +++ b/src/map-app/app/model/sparql-data-loader.ts @@ -64,7 +64,7 @@ export class SparqlDataLoader implements DataLoader { const response: SparqlDatasetResponse = await this.fetchDataset(this.id); console.debug(`loaded ${this.id} data`, response); - this.meta = { // Fill in any missing fields in the JSON + this.meta = { // Fill in any missing properties in the JSON endpoint: response.meta?.endpoint ?? '', default_graph_uri: response?.meta?.default_graph_uri ?? '', query: response?.meta?.query ?? '' @@ -79,9 +79,9 @@ export class SparqlDataLoader implements DataLoader { // We have to be aware that an initiative can be spread // across several records, due to the way that a SPARQL - // response encodes fields with multiple values as - // multiple records with the non-multiple fields - // duplicated, and the multiple fields varying. + // response encodes properties with multiple values as + // multiple records with the non-multiple properties + // duplicated, and the multiple properties varying. // // Therefore, read in records with the same uri all at once while(batch.length < this.maxInitiativesToLoadPerFrame) { @@ -127,7 +127,7 @@ export class SparqlDataLoader implements DataLoader { // { uri: 'yyy', multivalue: undefined }, // { uri: 'zzz', multivalue: 4 }] // - // Note that yyy and zzz's multivalue fields are represented by a + // Note that yyy and zzz's multivalue properties are represented by a // single non-array value. This is due to the lack of any metadata // at the time of pre-processing. Note also that this applies to // nulls or undefineds, which need to be interpreted downstream as @@ -140,7 +140,7 @@ export class SparqlDataLoader implements DataLoader { // data. // // The plan is to make the server send more sensibly formatted - // multiple fields later. + // multiple properties later. private readInitiativeObj(records: InitiativeObj[]): InitiativeObj | undefined { if (records.length === 0) return undefined; // No more records @@ -149,9 +149,9 @@ export class SparqlDataLoader implements DataLoader { // Add any more with the same uri Note, we're a bit limited by // what we can infer without having access to the expected - // property schema, or any metadata from SPARQL about the field + // property schema, or any metadata from SPARQL about the property // types. If we get multiple identical values in a multivalue - // field, we might not be able to tell it isn't single valued. + // property, we might not be able to tell it isn't single valued. // Live with this for now, later move this code to the // server-side, where this metadata is available. while(records.length > 0 && records[0].uri === first?.uri) { @@ -191,7 +191,7 @@ export class SparqlDataLoader implements DataLoader { // // @return the response data wrapped in a promise, direct from d3.json. // The data should be a SparqlDatasetResponse object with the properties: - // - `data`: [Array] list of inititive definitions, each a map of field names to values + // - `data`: [Array] list of inititive definitions, each a map of property names to values // - `meta`: [Object] a map of the following information: // - `endpoint`: [String] the SPARQL endpoint queried // - `query`: [String] the SPARQL query used diff --git a/src/map-app/app/state-manager.ts b/src/map-app/app/state-manager.ts index f8d88a97..36480c76 100644 --- a/src/map-app/app/state-manager.ts +++ b/src/map-app/app/state-manager.ts @@ -1,6 +1,6 @@ import { Dictionary } from "../common-types"; import { StateChange, UndoStack } from "../undo-stack"; -import { compactArray } from "../utils"; +import { compactArray, filterSet } from "../utils"; import { Initiative } from "./model/initiative"; export type Action = TextSearch | PropEquality | ClearPropEquality | ClearPropEqualities; @@ -29,23 +29,66 @@ export class AppState { readonly propFilters: Dictionary = {}, ) {} + // Helper method to construct a start state from some initiatives and start + // actions + static startState(allInitiatives: Set, + startingTextSearch?: TextSearch, + startingFilters?: Partial>): AppState { + // `startingFilters` may be an empty collection if no default filters + // are set in the config. Which results in `initialInitiatives` being + // the same list as `allInitiatives`. + let initiatives = this.applyTextSearch(allInitiatives, startingTextSearch); + initiatives = this.applyPropFilters(initiatives, startingFilters); + return new AppState( + allInitiatives, + initiatives, + startingTextSearch, + startingFilters, + ); + } + + restartState(allInitiatives: Set): AppState { + return AppState.startState( + allInitiatives, + this.textSearch, + this.propFilters, + ); + } + // Helper function which applies string search to initiative set - private applyTextSearch(textSearch: TextSearch, initiatives: Set): Initiative[] { - if (!textSearch.willMatch()) - return Array.from(initiatives).filter(textSearch.predicate); - return Array.from(initiatives); + static applyTextSearch(initiatives: Set, textSearch?: TextSearch): Set { + if (!textSearch) + return initiatives; + if (textSearch.willMatch()) + return initiatives; + return filterSet(initiatives, textSearch.predicate); } // Helper function which applies property filters to initiative array - private applyPropFilters(propFilters: Dictionary, initiatives: Initiative[]): Initiative[] { + static applyPropFilters(initiatives: Set, propFilters?: Dictionary): Set { + if (!propFilters) + return initiatives; for(const propName in propFilters) { const filter = propFilters[propName]; if (filter) - initiatives = initiatives.filter(filter.predicate); + initiatives = filterSet(initiatives, filter.predicate); } return initiatives; } + // Helper function which applies string search to initiative set + private applyTextSearch(initiatives: Set, + textSearch?: TextSearch) { + return AppState.applyTextSearch(initiatives, textSearch); + } + + // Helper function which applies property filters to initiative array + private applyPropFilters(initiatives: Set, + propFilters?: Dictionary) { + return AppState.applyPropFilters(initiatives, propFilters); + } + + /// Finds all the possible values for propName not excluded by the other filters /// /// i.e. if we wanted to select another PropEquality value for propName, @@ -54,7 +97,7 @@ export class AppState { // We can't easily check if propName is a valid one here... // If it is, an empty set will be returned. - let initiatives = this.applyTextSearch(this.textSearch, this.allInitiatives); + let initiatives = this.applyTextSearch(this.allInitiatives, this.textSearch); // Filter all initiatives but the one for propName for(const filterPropName in this.propFilters) { @@ -62,13 +105,13 @@ export class AppState { continue; // skip the filter for this property const filter = this.propFilters[filterPropName]; if (filter) - initiatives = initiatives.filter(filter.predicate); + initiatives = filterSet(initiatives, filter.predicate); } // Find the variation in the property, not including undefined. // Assume any arrays are multi-valued fields // (this is possibly a bit slack but it works for now) - const fieldVals = compactArray(initiatives.flatMap(it => it[propName])); + const fieldVals = compactArray(Array.from(initiatives).flatMap(it => it[propName])); return new Set(fieldVals); } @@ -84,8 +127,8 @@ export class AppState { if (textSearch.searchText === this.textSearch.searchText) return undefined; // No change. - let initiatives = this.applyTextSearch(textSearch, this.allInitiatives); - initiatives = this.applyPropFilters(this.propFilters, initiatives); + let initiatives = this.applyTextSearch(this.allInitiatives, textSearch); + initiatives = this.applyPropFilters(initiatives, this.propFilters); const result = new AppState( this.allInitiatives, @@ -107,12 +150,12 @@ export class AppState { && oldPropFilter.propName === propEq.propName) return undefined; // No change - let initiatives = this.applyTextSearch(this.textSearch, this.allInitiatives); + let initiatives = this.applyTextSearch(this.allInitiatives, this.textSearch); const propFilters = { ...this.propFilters }; propFilters[propEq.propName] = propEq; - initiatives = this.applyPropFilters(propFilters, initiatives); + initiatives = this.applyPropFilters(initiatives, propFilters); const result = new AppState( this.allInitiatives, @@ -128,12 +171,12 @@ export class AppState { return undefined; // No change const action = new ClearPropEquality(propName); - let initiatives = this.applyTextSearch(this.textSearch, this.allInitiatives); + let initiatives = this.applyTextSearch(this.allInitiatives, this.textSearch); const propFilters = { ...this.propFilters }; delete propFilters[propName]; - initiatives = this.applyPropFilters(propFilters, initiatives); + initiatives = this.applyPropFilters(initiatives, propFilters); const result = new AppState( this.allInitiatives, new Set(initiatives), @@ -147,7 +190,7 @@ export class AppState { if (Object.keys(this.propFilters).length === 0) return undefined; // No change - let initiatives = this.applyTextSearch(this.textSearch, this.allInitiatives); + let initiatives = this.applyTextSearch(this.allInitiatives, this.textSearch); const action = new ClearPropEqualities(); const result = new AppState( @@ -262,7 +305,7 @@ export class StateManager { if (initiatives) { const initiativesSet = new Set(initiatives); - const state = new AppState(initiativesSet); + const state = this.stack.current.result.restartState(initiativesSet); const change = new StateChange(undefined, state); this.stack.current = change; this.onChange(change); // This is unambiguously a change! diff --git a/src/map-app/app/view/sidebar.ts b/src/map-app/app/view/sidebar.ts index 23dd864a..ac43ed16 100644 --- a/src/map-app/app/view/sidebar.ts +++ b/src/map-app/app/view/sidebar.ts @@ -43,7 +43,7 @@ export class SidebarView extends BaseView { .attr("class", "w3-button w3-border-0 ml-auto") .attr("title", labels.showDirectory) .on("click", () => { - this.presenter.mapui.removeFilters(); + this.presenter.mapui.resetSearch(); //notify zoom this.presenter.changeSidebar("directory"); this.showInitiativeList(); diff --git a/src/map-app/app/view/sidebar/directory.ts b/src/map-app/app/view/sidebar/directory.ts index 669ed9f1..7fa36d3d 100644 --- a/src/map-app/app/view/sidebar/directory.ts +++ b/src/map-app/app/view/sidebar/directory.ts @@ -134,7 +134,7 @@ export class DirectorySidebarView extends BaseSidebarView { .classed(classname, true) .classed("sea-directory-field", true) .on("click", (event: MouseEvent) => { - this.presenter.parent.mapui.removeFilters(); + this.presenter.parent.mapui.resetSearch(); this.listInitiativesForSelection(propName, propValue); // key may be null this.resetFilterSearch(); d3.select(".sea-field-active").classed("sea-field-active", false); @@ -151,10 +151,10 @@ export class DirectorySidebarView extends BaseSidebarView { const mapui = this.presenter.parent.mapui; const registeredValues = mapui.dataServices.getAggregatedData().registeredValues; - const filterableFields = mapui.config.getFilterableFields(); - const directoryPropName: string|undefined = filterableFields[0]; + const filterableFields = mapui.config.getFilteredPropDefs(); + const directoryPropName: string|undefined = Object.keys(filterableFields)[0]; if (directoryPropName === undefined) - throw new Error(`filterableFields is empty - so no directory can be rendered. Shouldn't happen!`); + throw new Error(`there are no filtered fields - so no directory can be rendered. Shouldn't happen!`); // Just run on the first property for now // TODO: Support user selectable fields diff --git a/src/map-app/app/view/sidebar/initiatives.ts b/src/map-app/app/view/sidebar/initiatives.ts index 519a0cb1..69a44237 100644 --- a/src/map-app/app/view/sidebar/initiatives.ts +++ b/src/map-app/app/view/sidebar/initiatives.ts @@ -118,18 +118,15 @@ export class InitiativesSidebarView extends BaseSidebarView { private createAdvancedSearch(container: d3DivSelection) { const mapui = this.presenter.parent.mapui; - const propNames = mapui.config.getFilterableFields(); + const filtered = mapui.config.getFilteredPropDefs(); const vocabs = mapui.dataServices.getVocabs(); const lang = mapui.config.getLanguage(); - for(const propName of propNames) { - const propDef = mapui.config.fields()[propName]; - if (!propDef) { - throw new Error(`filterableFields contains ${propName}, which is not a valid field`); - } + for(const propName in filtered) { + const propDef = filtered[propName]; const uri = propDefToVocabUri(propDef); if (!uri) { - throw new Error(`filterableFields contains ${propName}, which is not a valid vocab field`); + throw new Error(`to be a filtered property, '${propName}' should be a vocab field, but is not`); } let propTitle = propName; diff --git a/src/map-app/utils.ts b/src/map-app/utils.ts index 15e5de7a..9d4d6707 100644 --- a/src/map-app/utils.ts +++ b/src/map-app/utils.ts @@ -240,3 +240,14 @@ export function assert(expr: unknown, msg?: string): asserts expr { } export type Predicate = (it: T) => boolean; + +// Filters a Set with a predicate, returning a new Set. +export function filterSet(set: Set, predicate: Predicate): Set { + const result = new Set(); + set.forEach(item => { + if (predicate(item)) + result.add(item); + }); + return result; +} +