Skip to content

Commit

Permalink
fix: loading remote boundaries, integration with formula dependency s…
Browse files Browse the repository at this point in the history
…ystem
  • Loading branch information
kswenson committed Nov 24, 2024
1 parent 9908abb commit 061dd98
Show file tree
Hide file tree
Showing 27 changed files with 415 additions and 141 deletions.
1 change: 0 additions & 1 deletion v3/src/boundaries/US_State_Boundaries.json

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import { clsx } from "clsx"
import { format } from "d3-format"
import React from "react"
import { measureText } from "../../hooks/use-measure-text"
import { boundaryObjectFromBoundaryValue } from "../../models/boundaries/boundary-types"
import { IAttribute } from "../../models/data/attribute"
import { kDefaultNumPrecision } from "../../models/data/attribute-types"
import { boundaryObjectFromBoundaryValue } from "../../utilities/boundary-utils"
import { parseColor } from "../../utilities/color-utils"
import { isStdISODateString } from "../../utilities/date-iso-utils"
import { parseDate } from "../../utilities/date-parser"
Expand Down
14 changes: 12 additions & 2 deletions v3/src/components/common/formula-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import CodeMirror, {
import React, { useCallback, useRef } from "react"
import { useMemo } from "use-memo-one"
import { useDataSetContext } from "../../hooks/use-data-set-context"
import { boundaryManager } from "../../models/boundaries/boundary-manager"
import { IDataSet } from "../../models/data/data-set"
import { typedFnRegistry } from "../../models/formula/functions/math"
import { formulaLanguageWithHighlighting } from "../../models/formula/lezer/formula-language"
Expand All @@ -20,14 +21,15 @@ import { FormulaEditorApi, useFormulaEditorContext } from "./formula-editor-cont

interface ICompletionOptions {
attributes: boolean
boundaries: boolean
constants: boolean
functions: boolean
globals: boolean
specials: boolean
}

const kAllOptions: ICompletionOptions = {
attributes: true, constants: true, functions: true, globals: true, specials: true
attributes: true, boundaries: true, constants: true, functions: true, globals: true, specials: true
}

interface IProps {
Expand Down Expand Up @@ -114,6 +116,9 @@ function cmCodapCompletions(context: CompletionContext): CompletionResult | null
{ label: "π" }
] : []
const specials = options?.specials ? [{ label: "caseIndex" }] : []
const boundaries = options?.boundaries
? Array.from(boundaryManager.boundaryKeys.map(key => ({ label: key })))
: []

Check warning on line 121 in v3/src/components/common/formula-editor.tsx

View check run for this annotation

Codecov / codecov/patch

v3/src/components/common/formula-editor.tsx#L121

Added line #L121 was not covered by tests
const globalManager = dataSet && options?.globals
? getGlobalValueManager(getSharedModelManager(dataSet))
: undefined
Expand All @@ -139,7 +144,9 @@ function cmCodapCompletions(context: CompletionContext): CompletionResult | null
}
}
})) : []
const completions: Completion[] = [...attributes, ...constants, ...specials, ...globals, ...functions]
const completions: Completion[] = [
...attributes, ...constants, ...specials, ...boundaries, ...globals, ...functions
]

if (!before || before.to === before.from) return null

Expand All @@ -164,6 +171,9 @@ const highlightClasses: Record<string, HighlightFn> = {
if (options.attributes && data?.getAttributeByName(nodeText)) return "codap-attribute"
if (options.constants && ["e", "pi", "π"].includes(nodeText)) return "codap-constant"
if (options.specials && ["caseIndex"].includes(nodeText)) return "codap-special"

if (boundaryManager.isBoundarySet(nodeText)) return "codap-boundary"

const globalManager = options.globals ? getGlobalValueManager(getSharedModelManager(data)) : undefined
if (globalManager?.getValueByName(nodeText)) return "codap-global"
}
Expand Down
6 changes: 3 additions & 3 deletions v3/src/components/common/formula-insert-values-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import {Divider, Flex, List, ListItem,} from "@chakra-ui/react"
import { IAnyStateTreeNode } from "mobx-state-tree"
import React, { useEffect, useRef, useState } from "react"
import { useDataSetContext } from "../../hooks/use-data-set-context"
import { boundaryManager } from "../../models/boundaries/boundary-manager"
import { getGlobalValueManager, getSharedModelManager } from "../../models/tiles/tile-environment"
import { boundaryKeys } from "../../utilities/boundary-utils"
import { useFormulaEditorContext } from "./formula-editor-context"

import "./formula-insert-menus.scss"
Expand Down Expand Up @@ -54,7 +54,7 @@ export const InsertValuesMenu = ({setShowValuesMenu}: IProps) => {
maxItemLength.current = attrName.length
}
})
boundaryKeys.forEach((boundary) => {
boundaryManager.boundaryKeys.forEach((boundary) => {
if (boundary.length > maxItemLength.current) {
maxItemLength.current = boundary.length
}
Expand Down Expand Up @@ -149,7 +149,7 @@ export const InsertValuesMenu = ({setShowValuesMenu}: IProps) => {
</List>
<Divider className="list-divider"/>
<List className="formula-operand-subset">
{ boundaryKeys.map((boundary) => {
{ boundaryManager.boundaryKeys.map((boundary) => {
return (
<ListItem key={boundary} className="formula-operand-list-item"
onClick={() => insertValueToFormula(boundary)}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ const getTestEnv = () => {
}
const api = {
getDatasets: jest.fn(() => dataSets),
getBoundaryManager: jest.fn(),
getGlobalValueManager: jest.fn(),
getFormulaExtraMetadata: jest.fn(() => extraMetadata),
getFormulaContext: jest.fn(() => context),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ const getTestEnv = () => {
}
const api = {
getDatasets: jest.fn(() => dataSets),
getBoundaryManager: jest.fn(),
getGlobalValueManager: jest.fn(),
getFormulaExtraMetadata: jest.fn(() => extraMetadata),
getFormulaContext: jest.fn(() => context),
Expand Down
2 changes: 1 addition & 1 deletion v3/src/components/map/utilities/map-utils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {LatLngBounds, latLngBounds} from 'leaflet'
import {singular} from "pluralize"
import {kPolygonNames} from "../../../models/boundaries/boundary-types"
import {IDataSet} from "../../../models/data/data-set"
import { kPolygonNames } from "../../../utilities/boundary-utils"
import {isFiniteNumber} from "../../../utilities/math-utils"
import {translate} from "../../../utilities/translation/translate"
import {IDataConfigurationModel} from "../../data-display/models/data-configuration-model"
Expand Down
124 changes: 124 additions & 0 deletions v3/src/models/boundaries/boundary-manager.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { waitFor } from "@testing-library/react"
import { BoundaryManager, boundaryManager } from "./boundary-manager"

const kStateBoundaries = "US_state_boundaries"

describe("BoundaryManager", () => {
it("works when initialized/empty", () => {
expect(boundaryManager.boundaryKeys).toEqual([])
expect(boundaryManager.isBoundarySet()).toBe(false)
expect(boundaryManager.isBoundarySet("foo")).toBe(false)
expect(boundaryManager.hasBoundaryData()).toBe(false)
expect(boundaryManager.hasBoundaryData("foo")).toBe(false)
expect(boundaryManager.isBoundaryDataPending()).toBe(false)
expect(boundaryManager.isBoundaryDataPending("foo")).toBe(false)
expect(boundaryManager.getBoundaryData("foo", "bar")).toBeUndefined()
})

it("fetches boundary data successfully", async () => {
// mock the initial boundary specs request
fetchMock.mockResponseOnce(JSON.stringify([
{
"name": kStateBoundaries,
"format": "codap",
"url": "US_State_Boundaries.codap"
}
]), {
headers: {
"content-type": "application/json"
}
})

const _boundaryManager = new BoundaryManager()
// request has been submitted but response has not been received
expect(_boundaryManager.boundaryKeys.length).toBe(0)
await waitFor(() => {
expect(_boundaryManager.boundaryKeys.length).toBe(1)
})
expect(_boundaryManager.isBoundarySet(kStateBoundaries)).toBe(true)

// mock the boundary data request
const kAlaskaBoundaryGuid = 1234
const kAlaskaBoundaryData = { geometry: {}, coordinates: {}, features: {} }
fetchMock.mockResponseOnce(JSON.stringify({
contexts: [{
collections: [
// [0] boundary collection
{
cases: [{
guid: kAlaskaBoundaryGuid,
values: {
boundary: kAlaskaBoundaryData
}
}]
},
// [1] key collection
{
cases: [{
parent: kAlaskaBoundaryGuid,
values: {
key: "alaska"
}
}]
}
]
}]
}))
// request the boundary data
_boundaryManager.getBoundaryData(kStateBoundaries, "Alaska")
// boundary data is pending
expect(_boundaryManager.hasBoundaryData(kStateBoundaries)).toBe(false)
expect(_boundaryManager.isBoundaryDataPending(kStateBoundaries)).toBe(true)
await waitFor(() => {
expect(_boundaryManager.hasBoundaryData(kStateBoundaries)).toBe(true)
})
expect(_boundaryManager.isBoundaryDataPending(kStateBoundaries)).toBe(false)
expect(_boundaryManager.getBoundaryData(kStateBoundaries, "Alaska")).toEqual(kAlaskaBoundaryData)
expect(_boundaryManager.hasBoundaryDataError(kStateBoundaries)).toBe(false)
})

it("logs error when failing to fetch boundary specs", () => {
fetchMock.mockRejectOnce(new Error("not found"))

jestSpyConsole("error", async spy => {
const _boundaryManager = new BoundaryManager()
await waitFor(() => {
expect(spy).toHaveBeenCalledTimes(1)
})
expect(_boundaryManager.boundaryKeys.length).toBe(0)
})
})

it("logs error when failing to fetch boundary data", async () => {
// mock the initial boundary specs request
fetchMock.mockResponseOnce(JSON.stringify([
{
"name": kStateBoundaries,
"format": "codap",
"url": "US_State_Boundaries.codap"
}
]), {
headers: {
"content-type": "application/json"
}
})

const _boundaryManager = new BoundaryManager()
await waitFor(() => {
expect(_boundaryManager.boundaryKeys.length).toBe(1)
})

fetchMock.mockRejectOnce(new Error("not found"))

jestSpyConsole("error", async spy => {
const boundaryData = _boundaryManager.getBoundaryData(kStateBoundaries, "Alaska")
expect(boundaryData).toBeUndefined()
expect(_boundaryManager.isBoundaryDataPending(kStateBoundaries)).toBe(true)
await waitFor(() => {
expect(spy).toHaveBeenCalledTimes(1)
})
expect(_boundaryManager.isBoundaryDataPending(kStateBoundaries)).toBe(false)
expect(_boundaryManager.hasBoundaryDataError(kStateBoundaries)).toBe(true)
})
})
})
98 changes: 98 additions & 0 deletions v3/src/models/boundaries/boundary-manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { computed, makeObservable, observable } from "mobx"
import { kBoundariesRootUrl, kBoundariesSpecUrl, BoundaryInfo, isBoundaryInfo } from "./boundary-types"

export class BoundaryManager {
@observable.shallow
private boundaryMap = new Map<string, BoundaryInfo>()

// separate observable for remote boundaries for observability
@observable.shallow
private remoteBoundaries = new Map<string, any>()

constructor() {
makeObservable(this)

this.fetchBoundarySpecs()
}

@computed
get boundaryKeys() {
return Array.from(this.boundaryMap.keys())
}

async fetchBoundarySpecs() {
try {
const response = await fetch(kBoundariesSpecUrl)

if (response.ok && response.headers.get("content-type")?.includes("application/json")) {
const boundariesSpecs = await response.json()

boundariesSpecs.forEach((boundariesSpec: any) => {
if (isBoundaryInfo(boundariesSpec)) {
this.boundaryMap.set(boundariesSpec.name, boundariesSpec)
}
})
}
} catch (error) {
console.error("Error fetching boundary specs:", error)
}
}

isBoundarySet(name?: string) {
return !!name && typeof name === "string" && !!this.boundaryMap.get(name)
}

hasBoundaryData(name?: string) {
return !!name && typeof name === "string" && !!this.remoteBoundaries.get(name)
}

hasBoundaryDataError(name?: string) {
return !!name && typeof name === "string" && !!this.boundaryMap.get(name)?.error
}

isBoundaryDataPending(name?: string) {
if (!name || !this.isBoundarySet(name)) return false
return !!this.boundaryMap.get(name)?.promise && !this.hasBoundaryData(name) && !this.hasBoundaryDataError(name)
}

processBoundaryData(boundaryDocument: any) {
const dataset = boundaryDocument.contexts[0]
const boundaryCollection = dataset.collections[0]
const boundaries: Record<number, string> = {}
boundaryCollection.cases.forEach((aCase: any) => boundaries[aCase.guid] = aCase.values.boundary)

const keyCollection = dataset.collections[1]
const _boundaryMap: Record<string, string> = {}
keyCollection.cases.forEach((aCase: any) => _boundaryMap[aCase.values.key] = boundaries[aCase.parent])
return _boundaryMap
}

getBoundaryData(boundarySet: string, boundaryKey: string) {
if (!this.isBoundarySet(boundarySet)) return

// Return the boundary data if it has already been fetched and cached
const remoteBoundary = this.remoteBoundaries.get(boundarySet)
if (remoteBoundary) return remoteBoundary[boundaryKey.toLowerCase()]

// If the boundary info has not yet been fetched, fetch it and return a pending message
const boundaryInfo = this.boundaryMap.get(boundarySet)
if (boundaryInfo && !boundaryInfo.promise) {
boundaryInfo.promise = (async () => {
try {
const boundaryResponse = await fetch(`${kBoundariesRootUrl}/${boundaryInfo.url}`)
const boundary = await boundaryResponse.json()
boundaryInfo.boundary = this.processBoundaryData(boundary)
this.remoteBoundaries.set(boundarySet, boundaryInfo.boundary)
return boundary
}
catch (error) {
console.error(`Error fetching boundary data for "${boundarySet}":`, error)
boundaryInfo.error = error
}
})()
}

}
}

export const boundaryManager = new BoundaryManager()
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { boundaryObjectFromBoundaryValue, isBoundaryValue } from "./boundary-utils"
import { boundaryObjectFromBoundaryValue, isBoundaryValue } from "./boundary-types"

describe("boundaryObjectFromBoundaryValue", () => {
it("Returns the correct value", () => {
Expand Down
38 changes: 38 additions & 0 deletions v3/src/models/boundaries/boundary-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { codapResourcesUrl } from "../../constants"

export const kBoundariesRootUrl = codapResourcesUrl("boundaries")
export const kBoundariesSpecUrl = `${kBoundariesRootUrl}/default_boundary_specs.json`

// TODO: localize this list properly
export const kPolygonNames = ['boundary', 'boundaries', 'polygon', 'polygons', 'grenze', '境界', 'مرز']

export const boundaryObjectFromBoundaryValue = (iBoundaryValue: object | string) => {
if (typeof iBoundaryValue === 'object') {
return iBoundaryValue
} else {
try {
return JSON.parse(iBoundaryValue)
} catch (er) {
return null
}
}
}

export const isBoundaryValue = (iValue: object | string): boolean => {
const obj = boundaryObjectFromBoundaryValue(iValue)
return obj != null &&
!!(obj.geometry || obj.coordinates || obj.features ||
obj.type === 'FeatureCollection' || obj.type === 'Feature' || obj.jsonBoundaryObject)
}

export interface BoundaryInfo {
name: string
format: string
url: string
promise?: Promise<unknown>
boundary?: unknown
error?: unknown
}
export function isBoundaryInfo(obj: any): obj is BoundaryInfo {
return obj && typeof obj === "object" && "format" in obj && "name" in obj && "url" in obj
}
Loading

0 comments on commit 061dd98

Please sign in to comment.