diff --git a/packages/column-views/CHANGELOG.md b/packages/column-views/CHANGELOG.md index e005decf..085e7365 100644 --- a/packages/column-views/CHANGELOG.md +++ b/packages/column-views/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.1.0] - 2026-01-31 + +- Moved `MacrostratDataProvider` and data fetchers to + `@macrostrat/data-provider` for better modularity. +- Standardize approach to clickable/linkable data items in `UnitDetailsPanel`, + using a new `MacrostratInteractionProvider` from + `@macrostrat/data-components`. + ## [3.0.3] - 2026-01-29 - Change layout of `package.json` diff --git a/packages/column-views/package.json b/packages/column-views/package.json index 7f8c9bbe..d589194a 100644 --- a/packages/column-views/package.json +++ b/packages/column-views/package.json @@ -1,6 +1,6 @@ { "name": "@macrostrat/column-views", - "version": "3.0.3", + "version": "3.1.0", "description": "Data views for Macrostrat stratigraphic columns", "repository": { "type": "git", @@ -50,6 +50,7 @@ "@macrostrat/color-utils": "workspace:^", "@macrostrat/column-components": "workspace:^", "@macrostrat/data-components": "workspace:^", + "@macrostrat/data-provider": "workspace:^", "@macrostrat/hyper": "^3.0.6", "@macrostrat/map-interface": "workspace:^", "@macrostrat/map-styles": "workspace:^", diff --git a/packages/column-views/src/correlation-chart/stories/correlation-chart.stories.ts b/packages/column-views/src/correlation-chart/stories/correlation-chart.stories.ts index 78950fe3..a54c9796 100644 --- a/packages/column-views/src/correlation-chart/stories/correlation-chart.stories.ts +++ b/packages/column-views/src/correlation-chart/stories/correlation-chart.stories.ts @@ -4,14 +4,15 @@ import { useCorrelationLine } from "./utils"; import { ColumnCorrelationMap, ColumnCorrelationProvider, - fetchUnits, - MacrostratDataProvider, MergeSectionsMode, useCorrelationMapStore, - useMacrostratBaseURL, - useMacrostratFetch, } from "../.."; import { hyperStyled } from "@macrostrat/hyper"; +import { + MacrostratDataProvider, + fetchUnits, + useMacrostratFetch, +} from "@macrostrat/data-provider"; import styles from "./stories.module.sass"; import { CorrelationChart, CorrelationChartProps } from "../main"; @@ -19,6 +20,7 @@ import { ErrorBoundary, useAsyncMemo } from "@macrostrat/ui-components"; import { OverlaysProvider } from "@blueprintjs/core"; import { EnvironmentColoredUnitComponent } from "../../units"; import { scaleLinear, scalePow } from "d3-scale"; +import { MacrostratInteractionProvider } from "@macrostrat/data-components"; const mapboxToken = import.meta.env.VITE_MAPBOX_API_TOKEN; @@ -35,29 +37,34 @@ function CorrelationStoryUI({ projectID, ...rest }: any) { + const domain = "https://dev.macrostrat.org"; return h( MacrostratDataProvider, - { baseURL: "https://dev.macrostrat.org/api/v2" }, + { baseURL: domain + "/api/v2" }, h( - ColumnCorrelationProvider, - { - focusedLine, - columns: null, - projectID, - onSelectColumns(cols, line) { - setFocusedLine(line); + MacrostratInteractionProvider, + { linkDomain: domain }, + h( + ColumnCorrelationProvider, + { + focusedLine, + columns: null, + projectID, + onSelectColumns(cols, line) { + setFocusedLine(line); + }, }, - }, - h("div.correlation-ui", [ - h("div.correlation-container", h(CorrelationDiagramWrapper, rest)), - h("div.right-column", [ - h(ColumnCorrelationMap, { - accessToken: mapboxToken, - className: "correlation-map", - //showLogo: false, - }), + h("div.correlation-ui", [ + h("div.correlation-container", h(CorrelationDiagramWrapper, rest)), + h("div.right-column", [ + h(ColumnCorrelationMap, { + accessToken: mapboxToken, + className: "correlation-map", + //showLogo: false, + }), + ]), ]), - ]), + ), ), ); } diff --git a/packages/column-views/src/data-provider/core.ts b/packages/column-views/src/data-provider/core.ts deleted file mode 100644 index 809e22d5..00000000 --- a/packages/column-views/src/data-provider/core.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { atom, type WritableAtom } from "jotai"; -import { createIsolation } from "jotai-scope"; -import { BaseUnit } from "@macrostrat/api-types"; -import { ReactNode, useEffect, useRef } from "react"; -import h from "@macrostrat/hyper"; - -interface StateIsolation { - Provider: (props: { - store?: any; - initialValues?: AtomMap; - children: ReactNode; - }) => ReactNode; - useStore: () => any; - useAtom: any; - useAtomValue: any; - useSetAtom: any; -} - -export const scope: StateIsolation = createIsolation(); - -export type AtomMap = [WritableAtom, any][]; - -type ProviderProps = { - children: ReactNode; - atoms?: AtomMap; - shouldUpdateAtoms?: boolean; -}; - -export function ScopedProvider({ - children, - atoms, - shouldUpdateAtoms = true, -}: ProviderProps) { - // Always use the same store instance in this tree - let val = null; - try { - val = scope.useStore(); - // This store has already been provided - return children; - } catch { - return h(scope.Provider, { store: null, initialValues: atoms }, [ - h.if(shouldUpdateAtoms)(AtomUpdater, { atoms }), - children, - ]); - } -} - -function AtomUpdater({ - atoms, -}: { - atoms: [WritableAtom, any][]; -}) { - /** - * A generic updater to sync Jotai atoms with state passed as props. - * Useful for scoped providers where state needs to be synced outside - * the current context. - */ - /** TODO: this is an awkward way to keep atoms updated */ - // The scoped store - const store = scope.useStore(); - - // Warn on atoms changing length - const prevLengthRef = useRef(atoms.length); - useEffect(() => { - if (prevLengthRef.current !== atoms.length) { - console.error("Error: number of atoms in ScopedAtomUpdater has changed."); - prevLengthRef.current = atoms.length; - } - }, [atoms.length]); - - for (const [atom, value] of atoms) { - useEffect(() => { - store.set(atom, value); - }, [store, value]); - } - return null; -} - -export const columnUnitsAtom = atom(); - -export const columnUnitsMapAtom = atom | null>((get) => { - const units = get(columnUnitsAtom); - if (!units) return null; - const unitMap = new Map(); - units.forEach((unit) => { - unitMap.set(unit.unit_id, unit); - }); - return unitMap; -}); diff --git a/packages/column-views/src/data-provider/index.ts b/packages/column-views/src/data-provider/index.ts index fda8a356..9776965b 100644 --- a/packages/column-views/src/data-provider/index.ts +++ b/packages/column-views/src/data-provider/index.ts @@ -1,5 +1,3 @@ -export * from "./fetch"; -export * from "./base"; export * from "./column-nav"; export * from "./store"; export * from "./unit-selection"; diff --git a/packages/column-views/src/data-provider/store.ts b/packages/column-views/src/data-provider/store.ts index bcf274ff..11943ce7 100644 --- a/packages/column-views/src/data-provider/store.ts +++ b/packages/column-views/src/data-provider/store.ts @@ -4,7 +4,7 @@ import { CompositeColumnScale, createCompositeScale, } from "../prepare-units/composite-scale"; -import { ColumnAxisType } from "@macrostrat/column-components"; +import { ColumnAxisType, ColumnProvider } from "@macrostrat/column-components"; import type { ExtUnit, PackageLayoutData } from "../prepare-units"; import { allowUnitSelectionAtom, @@ -12,14 +12,23 @@ import { UnitSelectionCallbacks, UnitSelectionCallbackManager, } from "./unit-selection"; -import { BaseUnit } from "@macrostrat/api-types"; -import { - AtomMap, - columnUnitsAtom, - columnUnitsMapAtom, - scope, - ScopedProvider, -} from "./core"; +import type { BaseUnit } from "@macrostrat/api-types"; +import { type AtomMap, createScopedStore } from "@macrostrat/data-components"; +import { atom } from "jotai"; + +export const scope = createScopedStore(); + +export const columnUnitsAtom = atom(); + +export const columnUnitsMapAtom = atom | null>((get) => { + const units = get(columnUnitsAtom); + if (!units) return null; + const unitMap = new Map(); + units.forEach((unit) => { + unitMap.set(unit.unit_id, unit); + }); + return unitMap; +}); export interface ColumnStateProviderProps< T extends BaseUnit, @@ -64,9 +73,11 @@ export function MacrostratColumnStateProvider({ } return h( - ScopedProvider, + scope.Provider, { atoms: atomMap, + keepUpdated: true, + inherit: true, }, [selectionHandlers, children], ); @@ -170,3 +181,33 @@ export function useCompositeScale(): CompositeColumnScale { [ctx.sections], ); } + +export function MacrostratColumnProvider(props) { + /** A column provider specialized the Macrostrat API. Maps more + * generic concepts to Macrostrat-specific ones. + */ + + const { axisType } = useMacrostratColumnData(); + const { units, domain, pixelScale, scale, children } = props; + return h( + ColumnProvider, + { + axisType, + divisions: units, + range: domain, + pixelsPerMeter: pixelScale, + scale, + }, + children, + ); +} + +/** This is now a legacy provider */ +export function LithologiesProvider({ children }) { + useEffect(() => { + console.warn( + "LithologiesProvider is deprecated. Replace with MacrostratDataProvider", + ); + }, []); + return children; +} diff --git a/packages/column-views/src/data-provider/unit-selection.ts b/packages/column-views/src/data-provider/unit-selection.ts index 1014eabb..e5e3e6a8 100644 --- a/packages/column-views/src/data-provider/unit-selection.ts +++ b/packages/column-views/src/data-provider/unit-selection.ts @@ -1,15 +1,15 @@ -import { BaseUnit, UnitLong } from "@macrostrat/api-types"; +import type { BaseUnit, UnitLong } from "@macrostrat/api-types"; import { useKeyHandler } from "@macrostrat/ui-components"; import { useEffect, useRef, useCallback, useMemo } from "react"; import type { RectBounds, IUnit } from "../units/types"; import { atom } from "jotai"; -import { columnUnitsMapAtom, scope } from "./core"; -import { ColumnData } from "@macrostrat/column-views"; +import { scope, columnUnitsMapAtom } from "./store"; import { AgeRangeQuantifiedDifference, ageRangeQuantifiedDifference, AgeRangeRelationship, } from "@macrostrat/stratigraphy-utils"; +import type { ColumnData } from "@macrostrat/data-provider"; type UnitSelectDispatch = ( unit: number | BaseUnit | null, @@ -69,7 +69,7 @@ const selectedUnitAtom = atom( target: HTMLElement | null = null, ): BaseUnit | null => { if (!get(allowUnitSelectionAtom)) { - throw new Error("Unit selection is disabled."); + console.error("Unit selection is disabled."); } let unitID: number | null; diff --git a/packages/column-views/src/maps/column-correlation/state.ts b/packages/column-views/src/maps/column-correlation/state.ts index b4160c2d..9268542e 100644 --- a/packages/column-views/src/maps/column-correlation/state.ts +++ b/packages/column-views/src/maps/column-correlation/state.ts @@ -15,7 +15,7 @@ import { } from "react"; import h from "@macrostrat/hyper"; import { createComputed } from "zustand-computed"; -import { useMacrostratColumns } from "../../data-provider/base"; +import { useMacrostratColumns } from "@macrostrat/data-provider"; import { buffer } from "@turf/buffer"; import { booleanPointInPolygon } from "@turf/boolean-point-in-polygon"; @@ -26,6 +26,7 @@ export interface CorrelationMapInput { export interface CorrelationMapStore extends CorrelationMapInput { onClickMap: (event: mapboxgl.MapMouseEvent, point: Point) => void; + projectID?: number; } export interface CorrelationProviderProps extends CorrelationMapInput { diff --git a/packages/column-views/src/maps/column-navigation/mapbox/state.ts b/packages/column-views/src/maps/column-navigation/mapbox/state.ts index a4df0e7b..51d580e0 100644 --- a/packages/column-views/src/maps/column-navigation/mapbox/state.ts +++ b/packages/column-views/src/maps/column-navigation/mapbox/state.ts @@ -12,7 +12,7 @@ import h from "@macrostrat/hyper"; import { useMacrostratColumns, useMacrostratStore, -} from "../../../data-provider"; +} from "@macrostrat/data-provider"; export interface NavigationStore { columns: ColumnGeoJSONRecordWithID[]; diff --git a/packages/column-views/src/maps/column-navigation/svg/column-navigation-svg-map.stories.ts b/packages/column-views/src/maps/column-navigation/svg/column-navigation-svg-map.stories.ts index 4d01f963..28506a34 100644 --- a/packages/column-views/src/maps/column-navigation/svg/column-navigation-svg-map.stories.ts +++ b/packages/column-views/src/maps/column-navigation/svg/column-navigation-svg-map.stories.ts @@ -2,7 +2,7 @@ import h from "@macrostrat/hyper"; import { Meta } from "@storybook/react-vite"; import { ColumnNavigationSVGMap, ColumnNavigationSVGMapProps } from "."; -import { MacrostratDataProvider } from "../../../data-provider"; +import { MacrostratDataProvider } from "@macrostrat/data-provider"; import { useState } from "react"; interface ColumnIndexMapProps extends ColumnNavigationSVGMapProps { diff --git a/packages/column-views/src/maps/column-navigation/svg/data-fetcher.ts b/packages/column-views/src/maps/column-navigation/svg/data-fetcher.ts new file mode 100644 index 00000000..04db2201 --- /dev/null +++ b/packages/column-views/src/maps/column-navigation/svg/data-fetcher.ts @@ -0,0 +1,34 @@ +import { + ColumnStatusCode, + fetchAllColumns, + useMacrostratFetch, +} from "@macrostrat/data-provider"; +import { useAsyncMemo } from "@macrostrat/ui-components"; + +export function useColumnFeatures({ + status_code, + project_id, + format = "geojson", +}: { + apiRoute?: string; + status_code?: string; + project_id?: number; + format?: "geojson" | "topojson" | "geojson_bare"; +}) { + /** Legacy fetcher for column features */ + const fetch = useMacrostratFetch(); + + let statusCode: ColumnStatusCode[] = ["active"]; + if (status_code == "in process") { + statusCode.push("in process"); + } + + return useAsyncMemo(async () => { + return await fetchAllColumns({ + projectID: project_id, + statusCode: statusCode, + format, + fetch, + }); + }, [project_id, status_code, format]); +} diff --git a/packages/column-views/src/maps/column-navigation/svg/layers.ts b/packages/column-views/src/maps/column-navigation/svg/layers.ts index f263098d..34655ecc 100644 --- a/packages/column-views/src/maps/column-navigation/svg/layers.ts +++ b/packages/column-views/src/maps/column-navigation/svg/layers.ts @@ -9,7 +9,7 @@ import chroma from "chroma-js"; import { ExtendedFeature } from "d3-geo"; import { Polygon } from "geojson"; import { useContext, useMemo } from "react"; -import { useColumnFeatures } from "../../../data-provider"; +import { useColumnFeatures } from "./data-fetcher"; import { buildKeyMapping, buildTriangulation, diff --git a/packages/column-views/src/section.ts b/packages/column-views/src/section.ts index 31ad95f3..b93d350d 100644 --- a/packages/column-views/src/section.ts +++ b/packages/column-views/src/section.ts @@ -16,8 +16,8 @@ import { useMacrostratColumnData, useMacrostratUnits, MacrostratColumnProvider, - useMacrostratBaseURL, } from "./data-provider"; +import { useMacrostratBaseURL } from "@macrostrat/data-provider"; import { Duration } from "./unit-details"; import { Value } from "@macrostrat/data-components"; import type { ExtUnit, PackageScaleLayoutData } from "./prepare-units/types"; diff --git a/packages/column-views/src/unit-details/panel.ts b/packages/column-views/src/unit-details/panel.ts index 5a6ee73f..30a35694 100644 --- a/packages/column-views/src/unit-details/panel.ts +++ b/packages/column-views/src/unit-details/panel.ts @@ -2,7 +2,7 @@ import hyper from "@macrostrat/hyper"; import styles from "./panel.module.sass"; import { JSONView } from "@macrostrat/ui-components"; import { Button, ButtonGroup } from "@blueprintjs/core"; -import { ReactNode, useMemo, useState } from "react"; +import { ReactNode, useCallback, useMemo, useState } from "react"; import { DataField, EnvironmentsList, @@ -13,13 +13,21 @@ import { LithologyTagFeature, Parenthetical, Value, + MacrostratInteractionManager, + useInteractionProps, + ItemInteractionProps, + useInteractionManager, + MacrostratItemIdentifier, + MacrostratInteractionProvider, + isClickable, } from "@macrostrat/data-components"; +import { useColumnUnitsMap } from "../data-provider"; import { - useColumnUnitsMap, useMacrostratColumnInfo, useMacrostratData, useMacrostratDefs, -} from "../data-provider"; + useStratNames, +} from "@macrostrat/data-provider"; import type { Environment, UnitLong, @@ -42,6 +50,7 @@ export interface UnitDetailsPanelProps { lithologyFeatures?: Set; onSelectUnit?: (unitID: number) => void; onClickItem?: MacrostratItemClickHandler; + interactionManager?: MacrostratInteractionManager; } export function UnitDetailsPanel({ @@ -71,7 +80,6 @@ export function UnitDetailsPanel({ features, lithologyFeatures, onClickItem, - onSelectUnit, }); } @@ -94,7 +102,7 @@ export function UnitDetailsPanel({ ]); } - return h("div.unit-details-panel", { className }, [ + const main = h("div.unit-details-panel", { className }, [ h(LegendPanelHeader, { onClose, title, @@ -104,6 +112,36 @@ export function UnitDetailsPanel({ }), h("div.unit-details-content-holder", content), ]); + + /** Handle unit selection clicks */ + const clickHandlerForItem = useMemo(() => { + if (onSelectUnit == null && onClickItem == null) return null; + return (item: MacrostratItemIdentifier) => { + if ("unit_id" in item && !("col_id" in item)) { + // We are selecting a unit within the column + return (event: MouseEvent) => { + onSelectUnit(item.unit_id); + // Don't allow event to propagate further (e.g., to open a link) + event.preventDefault(); + }; + } + return undefined; + }; + }, [onSelectUnit, onClickItem]); + + if (clickHandlerForItem != null) { + // We wrap this in a MacrostratInteractionManager to handle unit selection + return h( + MacrostratInteractionProvider, + { + clickHandlerForItem, + inherit: true, + }, + main, + ); + } + + return main; } export function LegendPanelHeader({ @@ -162,7 +200,6 @@ export type MacrostratItemClickHandler = ( function UnitDetailsContent({ unit, - onSelectUnit, lithologyFeatures = new Set([ LithologyTagFeature.Proportion, LithologyTagFeature.Attributes, @@ -175,7 +212,6 @@ function UnitDetailsContent({ getItemHref, }: { unit: UnitLong; - onSelectUnit?: (unitID: number) => void; lithologyFeatures?: Set; features?: Set; onClickItem?: MacrostratItemClickHandler; @@ -243,12 +279,10 @@ function UnitDetailsContent({ col_id: unit.col_id, }), thicknessOrHeightRange, - h.if(lithologies)(LithologyList, { + h.if(lithologies != null)(LithologyList, { label: "Lithology", lithologies, features: lithologyFeatures, - getItemHref, - onClickItem, }), h(AgeField, { unit }, [ h(Parenthetical, h(Duration, { value: unit.b_age - unit.t_age })), @@ -257,10 +291,8 @@ function UnitDetailsContent({ onClickItem, }), ]), - h.if(environments)(EnvironmentsList, { + h.if(environments != null)(EnvironmentsList, { environments, - onClickItem, - getItemHref, }), h.if(unit.strat_name_id != null)(StratNameField, { strat_name_id: unit.strat_name_id, @@ -272,7 +304,6 @@ function UnitDetailsContent({ { label: "Above" }, h(UnitIDList, { units: unit.units_above, - onSelectUnit, showNames: true, }), ), @@ -281,7 +312,6 @@ function UnitDetailsContent({ { label: "Below" }, h(UnitIDList, { units: unit.units_below, - onSelectUnit, showNames: true, }), ), @@ -333,27 +363,35 @@ export function ReferencesField({ refs, className = null, ...rest }) { ); } -function StratNameField({ - strat_name_id, - onClickItem, -}: { - strat_name_id: number; - onClickItem?: (event: MouseEvent, item: { strat_name_id: number }) => void; -}) { - const stratNames = useMemo(() => [strat_name_id], [strat_name_id]); - const data = useMacrostratData("strat_names", stratNames); - const stratNameData = data?.[0]; +function useStratNameData(strat_name_id: number) { + const stratNames = useStratNames([strat_name_id]); + return stratNames?.[0]; +} + +function StratNameField( + props: { + strat_name_id: number; + className?: string; + } & ItemInteractionProps, +) { + /** Handling for stratigraphic name field */ + const { strat_name_id, className, ...rest } = props; + const data = useStratNameData(strat_name_id); + + const baseInteractionProps = useInteractionProps({ strat_name_id }); + + const coreProps = { + ...baseInteractionProps, + ...rest, + }; + let inner: any = h(Identifier, { id: strat_name_id }); - const name = stratNameData?.strat_name_long; + const name = data?.strat_name_long; if (name != null) { inner = h("span.strat-name", name); } - const clickable = onClickItem != null; - - const className = classNames({ - clickable, - }); + const clickable = isClickable(coreProps); return h( DataField, @@ -363,8 +401,8 @@ function StratNameField({ h( clickable ? "a" : "span", { - className, - onClick: (e) => onClickItem(e, { strat_name_id }), + className: classNames({ clickable }, className), + ...coreProps, }, inner, ), @@ -566,36 +604,32 @@ function enhanceLithologies( } export function ClickableText({ - onClick, className, - children, + ...rest }: { - onClick: () => void; className?: string; children: ReactNode; -}) { +} & ItemInteractionProps) { /** An optionally clickable text element */ - const tag = onClick != null ? "a" : "span"; - return h(tag, { onClick, className }, children); + const clickable = isClickable(rest); + const tag = clickable ? "a" : "span"; + return h(tag, { className: classNames(className, { clickable }), ...rest }); } export function Identifier({ id, - onClick, className, + ...rest }: { id: number | string; - onClick?: (id: number | string) => void; className?: string; -}) { +} & ItemInteractionProps) { /** An item that displays a numeric identifier, optionally clickable */ - const _onClick = onClick != null ? () => onClick(id) : null; - return h( ClickableText, { - onClick: _onClick, className: classNames("identifier", className), + ...rest, }, id, ); @@ -607,9 +641,11 @@ type UnitInfo = { name?: string; }; -function UnitIDList({ units, onSelectUnit, showNames = false }) { +function UnitIDList({ units, showNames = false }) { const unitsMap = useColumnUnitsMap(); + const interactionManager = useInteractionManager(); + const extUnits: UnitInfo[] = useMemo(() => { const u1 = units.filter((d) => d != 0); if (showNames) { @@ -637,9 +673,17 @@ function UnitIDList({ units, onSelectUnit, showNames = false }) { return h( ItemList, { className: "units-list" }, - extUnits.map((info) => - h("span.item", h(UnitIdentifier, { ...info, onSelectUnit })), - ), + extUnits.map((info) => { + return h( + "span.item", + h(UnitIdentifier, { + ...info, + ...interactionManager?.interactionPropsForItem({ + unit_id: info.unitID, + }), + }), + ); + }), ); } @@ -647,21 +691,14 @@ function UnitIdentifier({ unitID, colID, name, - onSelectUnit, -}: UnitInfo & { onSelectUnit?: (unitID: number) => void }) { - const onClick = useMemo(() => { - if (onSelectUnit == null) return null; - return () => { - onSelectUnit(unitID); - }; - }, [onSelectUnit]); - + ...interactionProps +}: UnitInfo & ItemInteractionProps) { if (name != null) { return h( ClickableText, { className: "unit-name", - onClick, + ...interactionProps, }, name, ); @@ -669,9 +706,9 @@ function UnitIdentifier({ return h(Identifier, { className: "unit-id", - onClick, key: unitID, id: unitID, + ...interactionProps, }); } diff --git a/packages/column-views/src/units/boxes.ts b/packages/column-views/src/units/boxes.ts index d3715623..4d263f09 100644 --- a/packages/column-views/src/units/boxes.ts +++ b/packages/column-views/src/units/boxes.ts @@ -12,7 +12,8 @@ import { SizeAwareLabel, Clickable } from "@macrostrat/ui-components"; import hyper from "@macrostrat/hyper"; import { forwardRef, ReactNode, useContext, useMemo } from "react"; import { resolveID, scalePattern } from "./resolvers"; -import { useUnitSelectionTarget, useLithologies } from "../data-provider"; +import { useUnitSelectionTarget } from "../data-provider"; +import { useLithologies } from "@macrostrat/data-provider"; import { IUnit } from "./types"; import styles from "./boxes.module.sass"; import classNames from "classnames"; diff --git a/packages/column-views/src/units/index.ts b/packages/column-views/src/units/index.ts index 5c0e984b..9f9fb91e 100644 --- a/packages/column-views/src/units/index.ts +++ b/packages/column-views/src/units/index.ts @@ -10,7 +10,7 @@ import { useColumnLayout } from "@macrostrat/column-components"; import { useInDarkMode } from "@macrostrat/ui-components"; import { getMixedUnitColor } from "./colors"; import { TrackedLabeledUnit } from "./composite"; -import { useEnvironments, useLithologies } from "../data-provider"; +import { useEnvironments, useLithologies } from "@macrostrat/data-provider"; import { useMemo } from "react"; import { resolveID } from "./resolvers"; import { Lithology } from "@macrostrat/api-types"; diff --git a/packages/column-views/stories/column-ui/story-ui.ts b/packages/column-views/stories/column-ui/story-ui.ts index 12f5f5c7..2fb10d6a 100644 --- a/packages/column-views/stories/column-ui/story-ui.ts +++ b/packages/column-views/stories/column-ui/story-ui.ts @@ -1,15 +1,11 @@ -import { - ColoredUnitComponent, - Column, - ColumnNavigationMap, - MacrostratDataProvider, -} from "../../src"; +import { ColoredUnitComponent, Column, ColumnNavigationMap } from "../../src"; import { hyperStyled } from "@macrostrat/hyper"; import styles from "./story-ui.module.sass"; import { Spinner } from "@blueprintjs/core"; import { useColumnBasicInfo, useColumnUnits } from "./utils"; import { UnitLong } from "@macrostrat/api-types"; +import { MacrostratDataProvider } from "@macrostrat/data-provider"; const mapboxToken = import.meta.env.VITE_MAPBOX_API_TOKEN; diff --git a/packages/column-views/stories/column-ui/utils.ts b/packages/column-views/stories/column-ui/utils.ts index 54ffcdff..f58ef0c2 100644 --- a/packages/column-views/stories/column-ui/utils.ts +++ b/packages/column-views/stories/column-ui/utils.ts @@ -2,7 +2,7 @@ import { useAPIResult } from "@macrostrat/ui-components"; import { useMemo, useCallback } from "react"; import { BaseUnit } from "@macrostrat/api-types"; import { useArgs } from "storybook/preview-api"; -import { useMacrostratBaseURL } from "@macrostrat/column-views"; +import { useMacrostratBaseURL } from "@macrostrat/data-provider"; export function useColumnUnits(col_id, inProcess = false): BaseUnit[] | null { const status_code = inProcess ? "in process" : undefined; diff --git a/packages/column-views/stories/facets/carbon-isotopes.stories.ts b/packages/column-views/stories/facets/carbon-isotopes.stories.ts index 01a3a3bb..e9da1579 100644 --- a/packages/column-views/stories/facets/carbon-isotopes.stories.ts +++ b/packages/column-views/stories/facets/carbon-isotopes.stories.ts @@ -1,6 +1,5 @@ import { IsotopesColumn, - MacrostratDataProvider, MeasurementDataProvider, ColumnNavigationSVGMap, MeasurementsLayer, @@ -11,6 +10,7 @@ import { StandaloneColumn } from "../column-ui"; import { FlexRow, useAPIResult } from "@macrostrat/ui-components"; import { useMemo } from "react"; import { FeatureCollection } from "geojson"; +import { MacrostratDataProvider } from "@macrostrat/data-provider"; function StableIsotopesOverlay(props) { return h(MeasurementDataProvider, { col_id: props.columnID }, [ diff --git a/packages/column-views/stories/facets/detrital-zircon.stories.ts b/packages/column-views/stories/facets/detrital-zircon.stories.ts index af99cb98..e2c9f788 100644 --- a/packages/column-views/stories/facets/detrital-zircon.stories.ts +++ b/packages/column-views/stories/facets/detrital-zircon.stories.ts @@ -1,11 +1,11 @@ import { ColoredUnitComponent, - MacrostratDataProvider, MeasurementDataProvider, ColumnNavigationMap, useColumnNav, DetritalColumn, } from "../../src"; +import { MacrostratDataProvider } from "@macrostrat/data-provider"; import h from "@macrostrat/hyper"; import { StandaloneColumn } from "../column-ui"; import { FlexRow, useAPIResult } from "@macrostrat/ui-components"; diff --git a/packages/column-views/stories/facets/fossils.stories.ts b/packages/column-views/stories/facets/fossils.stories.ts index 08abbc1e..9de5da25 100644 --- a/packages/column-views/stories/facets/fossils.stories.ts +++ b/packages/column-views/stories/facets/fossils.stories.ts @@ -1,6 +1,5 @@ import { ColoredUnitComponent, - MacrostratDataProvider, MergeSectionsMode, PBDBFossilsColumn, PBDBOccurrencesMatrix, @@ -10,6 +9,7 @@ import h from "@macrostrat/hyper"; import { StandaloneColumn } from "../column-ui"; import { Meta, StoryObj } from "@storybook/react-vite"; import { ColumnAxisType } from "@macrostrat/column-components"; +import { MacrostratDataProvider } from "@macrostrat/data-provider"; function PBDBFossilsDemoColumn(props) { const { id, children, type = FossilDataType.Collections, ...rest } = props; diff --git a/packages/column-views/stories/facets/sgp.stories.ts b/packages/column-views/stories/facets/sgp.stories.ts index fb11e918..53af6b83 100644 --- a/packages/column-views/stories/facets/sgp.stories.ts +++ b/packages/column-views/stories/facets/sgp.stories.ts @@ -1,7 +1,6 @@ import { ColoredUnitComponent, ColumnNavigationMap, - MacrostratDataProvider, MeasurementDataProvider, SGPMeasurementsColumn, } from "../../src"; @@ -9,6 +8,7 @@ import h from "@macrostrat/hyper"; import { StandaloneColumn } from "../column-ui"; import { Meta } from "@storybook/react-vite"; import { FlexRow } from "@macrostrat/ui-components"; +import { MacrostratDataProvider } from "@macrostrat/data-provider"; import { useColumnSelection } from "../column-ui/utils"; function SGPMeasurementsDemoColumn(props) { diff --git a/packages/column-views/stories/gbdb/gbdb-column-direct.stories.ts b/packages/column-views/stories/gbdb/gbdb-column-direct.stories.ts index c634b756..f821bb0d 100644 --- a/packages/column-views/stories/gbdb/gbdb-column-direct.stories.ts +++ b/packages/column-views/stories/gbdb/gbdb-column-direct.stories.ts @@ -6,7 +6,6 @@ import { Column, ColumnNavigationMap, MergeSectionsMode, - useLithologies, } from "../../src"; import "@macrostrat/style-system"; import { useMemo } from "react"; @@ -14,6 +13,7 @@ import { ColumnAxisType } from "@macrostrat/column-components"; import { useColumnSelection } from "../column-ui/utils"; import { Spinner } from "@blueprintjs/core"; import { convertGBDBUnitToMacrostrat, createFormationUnits } from "./utils"; +import { useLithologies } from "@macrostrat/data-provider"; const accessToken = import.meta.env.VITE_MAPBOX_API_TOKEN; diff --git a/packages/column-views/stories/gbdb/gbdb-column.stories.ts b/packages/column-views/stories/gbdb/gbdb-column.stories.ts index 5c66abde..fc3ba51e 100644 --- a/packages/column-views/stories/gbdb/gbdb-column.stories.ts +++ b/packages/column-views/stories/gbdb/gbdb-column.stories.ts @@ -6,15 +6,14 @@ import { Column, ColumnNavigationMap, MergeSectionsMode, - useLithologies, } from "../../src"; import "@macrostrat/style-system"; -import { UnitLong } from "@macrostrat/api-types"; import { useMemo } from "react"; import { ColumnAxisType } from "@macrostrat/column-components"; import { useColumnSelection } from "../column-ui/utils"; import { Spinner } from "@blueprintjs/core"; import { createFormationUnits, convertGBDBUnitToMacrostrat } from "./utils"; +import { useLithologies } from "@macrostrat/data-provider"; const accessToken = import.meta.env.VITE_MAPBOX_API_TOKEN; diff --git a/packages/column-views/stories/gbdb/gbdb-correlation.stories.ts b/packages/column-views/stories/gbdb/gbdb-correlation.stories.ts index 7c717eec..cc7b935e 100644 --- a/packages/column-views/stories/gbdb/gbdb-correlation.stories.ts +++ b/packages/column-views/stories/gbdb/gbdb-correlation.stories.ts @@ -3,9 +3,9 @@ import "@macrostrat/style-system"; import { ColumnCorrelationMap, ColumnCorrelationProvider, - ColumnData, useCorrelationMapStore, } from "@macrostrat/column-views"; +import type { ColumnData } from "@macrostrat/data-provider"; import { hyperStyled } from "@macrostrat/hyper"; import styles from "./gbdb.module.sass"; diff --git a/packages/column-views/stories/gbdb/utils.ts b/packages/column-views/stories/gbdb/utils.ts index f5bbd3cf..7ba8a75a 100644 --- a/packages/column-views/stories/gbdb/utils.ts +++ b/packages/column-views/stories/gbdb/utils.ts @@ -1,4 +1,4 @@ -import { UnitLong } from "@macrostrat/api-types"; +import type { UnitLong } from "@macrostrat/api-types"; interface UnitOutput extends UnitLong { covered: boolean; diff --git a/packages/column-views/stories/unit-details.stories.ts b/packages/column-views/stories/unit-details.stories.ts index b2e4659e..ad023434 100644 --- a/packages/column-views/stories/unit-details.stories.ts +++ b/packages/column-views/stories/unit-details.stories.ts @@ -3,7 +3,7 @@ import { Meta, StoryObj } from "@storybook/react-vite"; import { useAPIResult } from "@macrostrat/ui-components"; import { Button, Spinner } from "@blueprintjs/core"; import "@macrostrat/style-system"; -import { UnitDetailsPanel } from "../src"; +import { UnitDetailsPanel, UnitDetailsPanelProps } from "../src"; import { ExtUnit, LithologiesProvider, @@ -12,6 +12,7 @@ import { useUnitSelectionDispatch, } from "../src"; import { useColumnUnits } from "./column-ui/utils"; +import { MacrostratInteractionProvider } from "@macrostrat/data-components"; function useUnitData(unit_id, inProcess = false) { return useAPIResult( @@ -30,7 +31,7 @@ function UnitDetailsExt({ unit_id, inProcess, ...rest -}: UnitDetailsProps & { inProcess?: boolean }) { +}: UnitDetailsPanelProps & { inProcess?: boolean }) { const unit = useUnitData(unit_id, inProcess); if (unit == null) { @@ -49,15 +50,7 @@ function UnitDetailsExt({ type Story = StoryObj; -interface UnitDetailsProps { - unit_id: number; - onClose?: () => void; - showLithologyProportions?: boolean; - actions: any; - hiddenActions: any; -} - -const meta: Meta = { +const meta: Meta = { title: "Column views/Unit details", component: UnitDetailsExt, args: { @@ -131,7 +124,7 @@ export const WithActions: Story = { }, }; -export function WithDataProvider(args: UnitDetailsProps) { +export function WithDataProvider(args: UnitDetailsPanelProps) { const units = useColumnUnits(432) as ExtUnit[] | null; if (units == null) return h(Spinner); @@ -143,7 +136,7 @@ export function WithDataProvider(args: UnitDetailsProps) { ); } -function UnitDetailsWithSelection(args: UnitDetailsProps) { +function UnitDetailsWithSelection(args: Omit) { const unit = useSelectedUnit(); const setSelectedUnit = useUnitSelectionDispatch(); if (unit == null) return h("div", "No unit selected"); @@ -152,5 +145,21 @@ function UnitDetailsWithSelection(args: UnitDetailsProps) { onSelectUnit(unit) { setSelectedUnit(unit, null, null); }, + ...args, }); } + +export function WithExternalLinks(args: Omit) { + // Need to get column units first in order to set up navigation + const units = useColumnUnits(432) as ExtUnit[] | null; + + if (units == null) return h(Spinner); + + const domain = "https://dev.macrostrat.org"; + + return h( + MacrostratInteractionProvider, + { linkDomain: domain }, + h(UnitDetailsPanel, { unit: units[0] }), + ); +} diff --git a/packages/data-components/CHANGELOG.md b/packages/data-components/CHANGELOG.md index 0f26a933..c82fa015 100644 --- a/packages/data-components/CHANGELOG.md +++ b/packages/data-components/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [1.1.0] - 2026-01-31 + +- Create a `MacrostratInteractionProvider` to standardize handling of navigation + for clickable/linkable data items. +- Moved scoped data store utilities (based on `jotai-scope`) to this library. + ## [1.0.1] - 2026-01-29 - Change layout of `package.json` diff --git a/packages/data-components/package.json b/packages/data-components/package.json index 1e918573..03f80f4e 100644 --- a/packages/data-components/package.json +++ b/packages/data-components/package.json @@ -1,6 +1,6 @@ { "name": "@macrostrat/data-components", - "version": "1.0.1", + "version": "1.1.0", "description": "A library of React components tailored for Macrostrat data and endpoints", "repository": { "type": "git", @@ -60,6 +60,8 @@ "classnames": "^2.5.1", "cross-fetch": "^4.1.0", "d3-array": "^3.2.4", + "jotai": "^2.17.0", + "jotai-scope": "^0.10.0", "mapbox-gl": "^2.7.0||^3.13.0" }, "devDependencies": { diff --git a/packages/data-components/src/components/unit-details/base.ts b/packages/data-components/src/components/unit-details/base.ts index 272057fd..50dc34e6 100644 --- a/packages/data-components/src/components/unit-details/base.ts +++ b/packages/data-components/src/components/unit-details/base.ts @@ -7,6 +7,7 @@ import classNames from "classnames"; import { mergeAgeRanges } from "@macrostrat/stratigraphy-utils"; import { Tag, BaseTagProps } from "./tag"; import { ReactNode } from "react"; +import { ItemInteractionProps, useInteractionProps } from "../../data-links.ts"; export function DataField({ label, @@ -95,7 +96,8 @@ export function Value({ ]); } -interface IntervalTagProps extends Omit { +interface IntervalTagProps + extends Omit, ItemInteractionProps { interval: IntervalShort; showAgeRange?: boolean; } @@ -104,13 +106,13 @@ export function IntervalTag({ interval, showAgeRange = false, color, - onClick, ...rest }: IntervalTagProps) { + const interactionProps = useInteractionProps({ int_id: interval.id }); return h(Tag, { - onClick, name: interval.name, color: color ?? interval.color, + ...interactionProps, ...rest, }); } diff --git a/packages/data-components/src/components/unit-details/lithology-tag.ts b/packages/data-components/src/components/unit-details/lithology-tag.ts index 38ba9339..b81a757a 100644 --- a/packages/data-components/src/components/unit-details/lithology-tag.ts +++ b/packages/data-components/src/components/unit-details/lithology-tag.ts @@ -4,14 +4,22 @@ import { BaseTagProps, Tag, TagSize } from "./tag"; import { useMemo } from "react"; import { Lithology } from "@macrostrat/api-types"; import classNames from "classnames"; +import { + isClickable, + ItemInteractionProps, + MacrostratInteractionManager, + useInteractionManager, + useInteractionProps, +} from "../../data-links.ts"; -interface LithologyTagProps extends Omit { +interface LithologyTagProps + extends Omit, ItemInteractionProps { data: Lithology; className?: string; expandOnHover?: boolean; size?: TagSize; features?: Set; - onClick?: (event: any, data: Lithology) => void; + interactive?: boolean; } export enum LithologyTagFeature { @@ -23,7 +31,8 @@ export function LithologyTag({ data, color, features, - onClick: _onClick, + className, + interactive = true, ...rest }: LithologyTagProps) { let proportion = null; @@ -45,21 +54,22 @@ export function LithologyTag({ }); } - const onClick = useMemo(() => { - if (_onClick == null) return null; - return (event: MouseEvent) => { - _onClick(event, data); - }; - }, [data, _onClick]); + const interactionProps = useInteractionProps(data, interactive); - return h(Tag, { + const mainProps = { prefix: atts, details: proportion, name: data.name, - className: classNames({ clickable: onClick != null }, "lithology-tag"), color: color ?? data.color, - onClick, + ...interactionProps, ...rest, + }; + + const clickable = isClickable(mainProps); + + return h(Tag, { + ...mainProps, + className: classNames(className, { clickable }, "lithology-tag"), }); } @@ -94,18 +104,14 @@ export function LithologyList({ LithologyTagFeature.Proportion, LithologyTagFeature.Attributes, ]), - onClickItem, - getItemHref, className, + onClickItem, }: { label?: string; lithologies: any[]; features?: Set; - // Optional function to handle click events on each item - onClickItem?: (event: MouseEvent, data: Lithology) => void; - // Optional function to get a link location for each item - getItemHref?: (data: Lithology) => string | null | undefined; className?: string; + onClickItem?: (lith: Lithology) => void; }) { const sortedLiths = useMemo(() => { const l1 = [...lithologies]; @@ -122,12 +128,16 @@ export function LithologyList({ l1.prop = null; } - return h(LithologyTag, { + let props = { data: l1, features, - onClick: onClickItem, - href: getItemHref?.(lith), - }); + }; + + if (onClickItem != null) { + props.onClick = () => onClickItem(l1); + } + + return h(LithologyTag, props); }), ); } @@ -143,21 +153,28 @@ function lithologyComparison(a, b) { return dx; } +export interface EnvironmentsListProps { + environments: any[]; + label?: string; + onClickItem?: (env: any) => void; +} + export function EnvironmentsList({ environments, - onClickItem, - getItemHref, label = "Environments", -}) { + onClickItem, +}: EnvironmentsListProps) { return h( TagField, { label, className: "environments-list" }, environments.map((env: any) => { - return h(LithologyTag, { + let props: LithologyTagProps = { data: env, - onClick: onClickItem, - href: getItemHref?.(env), - }); + }; + if (onClickItem != null) { + props.onClick = () => onClickItem(env); + } + return h(LithologyTag, props); }), ); } diff --git a/packages/data-components/src/components/unit-details/tag.ts b/packages/data-components/src/components/unit-details/tag.ts index 690f8dd8..f2f50c54 100644 --- a/packages/data-components/src/components/unit-details/tag.ts +++ b/packages/data-components/src/components/unit-details/tag.ts @@ -5,6 +5,7 @@ import styles from "./tag.module.sass"; import { ComponentType, ReactNode, JSX } from "react"; import chroma from "chroma-js"; import classNames from "classnames"; +import { isClickable, ItemInteractionProps } from "../../data-links"; const h = hyper.styled(styles); @@ -14,7 +15,7 @@ export enum TagSize { Large = "large", } -export interface BaseTagProps { +export interface BaseTagProps extends ItemInteractionProps { prefix?: ReactNode; name: ReactNode; details?: ReactNode; @@ -27,8 +28,6 @@ export interface BaseTagProps { children?: ReactNode; size?: TagSize; color?: chroma.ChromaInput; - onClick?: (event: MouseEvent) => void; - href?: string; component?: ComponentOrHTMLTagElement; } @@ -48,16 +47,13 @@ export function Tag(props: BaseTagProps) { color, onClick, href, - component, } = props; let classes = props.classNames ?? {}; - let _component: ComponentOrHTMLTagElement = component ?? "span"; - if (href != null && component == null) { - // If a href is provided, use an anchor tag by default - _component = "a"; - } + const clickable = isClickable(props); + const baseTag = clickable ? "a" : "span"; + let component: any = props.component ?? baseTag; // TODO: details and prefix might be better moved outside of the component... let _details = null; @@ -77,12 +73,13 @@ export function Tag(props: BaseTagProps) { } return h( - _component, + component, { className: classNames(className, "tag"), style: buildTagStyle({ color, size, inDarkMode }), onClick, href, + target: clickable ? props.target : undefined, }, [_prefix, mainTag], ); diff --git a/packages/data-components/src/components/unit-details/unit-details.stories.ts b/packages/data-components/src/components/unit-details/unit-details.stories.ts index 2000e37e..01e90b8d 100644 --- a/packages/data-components/src/components/unit-details/unit-details.stories.ts +++ b/packages/data-components/src/components/unit-details/unit-details.stories.ts @@ -15,6 +15,10 @@ import { useToaster, ToasterContext, } from "@macrostrat/ui-components"; +import { + itemTypeHandlers, + MacrostratInteractionProvider, +} from "../../data-links"; export default { title: "Data components/Unit details", @@ -35,29 +39,49 @@ export function DataField() { }); } +const intervalData = [ + { + id: 1, + b_age: 10, + t_age: 0, + name: "Quaternary", + color: "blue", + rank: 1, + }, + { + id: 2, + b_age: 20, + t_age: 10, + name: "Neogene", + color: "green", + rank: 2, + }, +]; + export function IntervalField() { return h(_IntervalField, { - intervals: [ - { - id: 1, - b_age: 10, - t_age: 0, - name: "Quaternary", - color: "blue", - rank: 1, - }, - { - id: 2, - b_age: 20, - t_age: 10, - name: "Neogene", - color: "green", - rank: 2, - }, - ], + intervals: intervalData, }); } +export function IntervalFieldWithLinks() { + const hrefForItem = itemTypeHandlers({ + interval: (data) => { + return `https://dev.macrostrat.org/lex/intervals/${data.int_id}`; + }, + }); + + return h( + MacrostratInteractionProvider, + { + hrefForItem, + }, + h(_IntervalField, { + intervals: intervalData, + }), + ); +} + const LithologyTag = _LithologyTag as any; LithologyTag.args = { @@ -119,13 +143,13 @@ export function LithologyList() { lith_id: 2, }, ], - onClickItem: (e) => { - console.log("Clicked lith id:", e.lithId); + onClickItem: (data) => { + console.log("Clicked lith id:", data.lith_id); }, }); } -export function LithologyListClickable() { +export function LithologyListWithInteractionContext() { const toaster = useToaster(); const liths = useAPIResult( "https://dev.macrostrat.org/api/v2/defs/lithologies", @@ -139,15 +163,23 @@ export function LithologyListClickable() { return h("div", "Loading lithologies..."); } - return h(_LithologyList, { - lithologies: liths, - onClickItem: (e, data) => { + const clickHandlerForItem = itemTypeHandlers({ + lithology: (data) => (e) => { + console.log("Clicked lith id:", data.lith_id); toaster.show({ message: `Clicked lith ID: ${data.lith_id}`, intent: "success", }); }, }); + + return h( + MacrostratInteractionProvider, + { clickHandlerForItem }, + h(_LithologyList, { + lithologies: liths, + }), + ); } export function LithologyListWithLinks() { @@ -163,10 +195,19 @@ export function LithologyListWithLinks() { return h("div", "Loading lithologies..."); } - return h(_LithologyList, { - lithologies: liths, - getItemHref(data) { - return `https://dev.macrostrat.org/lex/lithology/${data.lith_id}`; + const hrefForItem = itemTypeHandlers({ + lithology: (data) => { + return `https://dev.macrostrat.org/lex/lithologies/${data.lith_id}`; }, }); + + return h( + MacrostratInteractionProvider, + { + hrefForItem, + }, + h(_LithologyList, { + lithologies: liths, + }), + ); } diff --git a/packages/data-components/src/data-links.ts b/packages/data-components/src/data-links.ts new file mode 100644 index 00000000..69130be0 --- /dev/null +++ b/packages/data-components/src/data-links.ts @@ -0,0 +1,286 @@ +import type { + Environment, + UnitLong, + Lithology, + Interval, +} from "@macrostrat/api-types"; +import { createScopedStore } from "./utils"; +import { atom } from "jotai"; +import { ReactNode, useMemo } from "react"; +import h from "@macrostrat/hyper"; + +export type MacrostratItemIdentifier = + | Lithology + | Environment + | UnitLong + | Interval + | { + strat_name_id: number; + } + | { lith_id: number } + | { environ_id: number } + | { unit_id: number } + | { int_id: number } + | { project_id: number } + | { col_id: number; unit_id?: number; project_id?: number }; + +export type MacrostratItemType = + | "lithology" + | "environment" + | "unit" + | "interval" + | "strat_name" + | "column" + | "project"; + +function identifierFields( + item: MacrostratItemIdentifier, +): [MacrostratItemType, ...any[]] { + /** Return a tuple of the item type and its identifier fields for useMemo dependencies */ + if ("strat_name_id" in item) { + return ["strat_name", item.strat_name_id]; + } else if ("lith_id" in item) { + return ["lithology", item.lith_id]; + } else if ("environ_id" in item) { + return ["environment", item.environ_id]; + } else if ("col_id" in item) { + return [ + "column", + item.col_id, + item.project_id ?? null, + item.unit_id ?? null, + ]; + } else if ("unit_id" in item) { + return ["unit", item.unit_id]; + } else if ("int_id" in item) { + return ["interval", item.int_id]; + } else if ("project_id" in item) { + return ["project", item.project_id]; + } + throw new Error("Invalid MacrostratItemIdentifier"); +} + +export function itemTypeHandlers( + builders: Partial>, +): T { + /** Helper to build either hrefs or click handlers based on item type */ + return ((item: MacrostratItemIdentifier) => { + const [itemType] = identifierFields(item); + const builder = builders[itemType] as T | undefined; + return builder?.(item); + }) as T; +} + +export interface ItemInteractionProps { + href?: string | null; + target?: string; + onClick?: (event: MouseEvent) => void; +} + +type HrefBuilder = (item: MacrostratItemIdentifier) => string | null; +type ClickHandlerBuilder = ( + item: MacrostratItemIdentifier, +) => ((event: MouseEvent) => void) | undefined; + +export interface MacrostratInteractionCtx { + interactionPropsForItem: MacrostratItemInteractionBuilder; +} + +export type MacrostratItemInteractionBuilder = ( + item: MacrostratItemIdentifier, +) => ItemInteractionProps; + +/** Begin: Store */ + +const scope = createScopedStore(); + +const interactionManagerAtom = atom(); + +export function MacrostratInteractionProvider({ + children, + inherit = true, + ...opts +}: InteractionManagerOptions & { + children: ReactNode; + inherit?: boolean; +}) { + /** Context provider for MacrostratInteractionManager */ + const parent = scope.useAtomValueIfExists(interactionManagerAtom); + const manager = useMemo(() => { + return new MacrostratInteractionManager({ + ...opts, + parent: inherit ? parent : null, + }); + }, [parent, inherit, opts]); + + return h( + scope.Provider, + { + atoms: [[interactionManagerAtom, manager]], + inherit: false, + keepUpdated: false, + }, + children, + ); +} + +export function useInteractionManager(): MacrostratInteractionManager | null { + /** Hook to access the MacrostratInteractionManager from context */ + return scope.useAtomValueIfExists(interactionManagerAtom); +} + +export function useInteractionProps( + item: MacrostratItemIdentifier, + interactive: boolean = true, +): ItemInteractionProps { + const manager = scope.useAtomValueIfExists(interactionManagerAtom); + return useMemo(() => { + if (!interactive) { + return {}; + } + return manager?.interactionPropsForItem(item) ?? {}; + }, [interactive, manager, ...identifierFields(item)]); +} + +interface InteractionManagerOptions { + linkDomain?: string; + hrefForItem?: (item: MacrostratItemIdentifier) => string | null; + clickHandlerForItem?: ( + item: MacrostratItemIdentifier, + ) => ((event: MouseEvent) => void) | undefined; + targetForItem?: ( + item: MacrostratItemIdentifier, + href: string, + ) => string | undefined; + interactionPropsForItem?: MacrostratItemInteractionBuilder; +} + +export class MacrostratInteractionManager implements MacrostratInteractionCtx { + /** Class to build interaction properties (links, click handlers, etc.) for Macrostrat items */ + readonly #linkDomain: string; + readonly #hrefForItem: HrefBuilder | undefined; + readonly #clickHandlerForItem: ClickHandlerBuilder | undefined; + readonly #interactionPropsForItem: + | MacrostratItemInteractionBuilder + | undefined; + readonly #parent: MacrostratInteractionManager | null; + readonly #targetForItem: ( + item: MacrostratItemIdentifier, + href: string, + ) => string; + + constructor( + options: InteractionManagerOptions & { + parent?: MacrostratInteractionManager | null; + } = {}, + ) { + const { + linkDomain, + hrefForItem, + clickHandlerForItem, + interactionPropsForItem, + targetForItem, + parent = null, + } = options; + this.#linkDomain = linkDomain; + this.#hrefForItem = hrefForItem; + this.#targetForItem = targetForItem; + this.#clickHandlerForItem = clickHandlerForItem; + this.#interactionPropsForItem = interactionPropsForItem; + this.#parent = parent; + } + + interactionPropsForItem( + item: MacrostratItemIdentifier, + ): ItemInteractionProps { + let res: ItemInteractionProps = { + href: undefined, + target: undefined, + onClick: undefined, + }; + + // If there's a parent item, defer to it for defaults + res = this.#parent?.interactionPropsForItem(item) ?? res; + + // If there are custom interaction props, use them and then return + if (this.#interactionPropsForItem != null) { + const customProps = this.#interactionPropsForItem(item) ?? {}; + console.log("Applying custom props"); + return { + ...res, + ...customProps, + }; + } + + // If there's a custom click handler, use it + if (this.#clickHandlerForItem != null) { + res.onClick ??= this.#clickHandlerForItem(item); + } + + // If there's a custom href builder, use it + const href = this.#hrefForItem?.(item) ?? this._defaultHrefForItem(item); + if (href != null) { + res.href = href; + res.target = this.#targetForItem?.(item, href) ?? defaultLinkTarget(href); + } + + return res; + } + + private _defaultHrefForItem(item: MacrostratItemIdentifier): string | null { + if (this.#linkDomain == null) { + return null; + } + let href = createItemHref(item); + if (href != null && this.#linkDomain != "/") { + if (!href.startsWith("/")) { + href = "/" + href; + } + href = this.#linkDomain + href; + } + return href; + } +} + +function createItemHref(item: MacrostratItemIdentifier): string { + /** Build a relative link to a Macrostrat item. Designed for the URL + * structure of Macrostrat's v2 website */ + if ("strat_name_id" in item) { + return `/lex/strat-names/${item.strat_name_id}`; + } else if ("lith_id" in item) { + return `/lex/lithologies/${item.lith_id}`; + } else if ("environ_id" in item) { + return `/lex/environments/${item.environ_id}`; + } else if ("col_id" in item) { + let href = `/columns/${item.col_id}`; + if (item.project_id != null) { + href = `/projects/${item.project_id}` + href; + } + if (item.unit_id != null) { + href += `#unit=${item.unit_id}`; + } + } else if ("unit_id" in item) { + return `/lex/units/${item.unit_id}`; + } else if ("int_id" in item) { + return `/lex/intervals/${item.int_id}`; + } else if ("project_id" in item) { + return `/projects/${item.project_id}`; + } + return null; +} + +function defaultLinkTarget(href: string | undefined) { + if (href == null) return undefined; + const isExternal = + href.startsWith("http://") || + href.startsWith("https://") || + href.startsWith("mailto:"); + if (isExternal) { + return "_blank"; + } + return undefined; +} + +export function isClickable(props: ItemInteractionProps): boolean { + return props.onClick != null || props.href != null; +} diff --git a/packages/data-components/src/index.ts b/packages/data-components/src/index.ts index 2f76699c..1eeb79d6 100644 --- a/packages/data-components/src/index.ts +++ b/packages/data-components/src/index.ts @@ -2,3 +2,5 @@ export * from "./components"; export * from "./dz-spectrum"; export * from "./field-locations"; export * from "./location-info"; +export * from "./data-links"; +export * from "./utils"; diff --git a/packages/data-components/src/utils/index.ts b/packages/data-components/src/utils/index.ts new file mode 100644 index 00000000..602dd34a --- /dev/null +++ b/packages/data-components/src/utils/index.ts @@ -0,0 +1 @@ +export * from "./scoped-store"; diff --git a/packages/data-components/src/utils/scoped-store.ts b/packages/data-components/src/utils/scoped-store.ts new file mode 100644 index 00000000..9f12cee1 --- /dev/null +++ b/packages/data-components/src/utils/scoped-store.ts @@ -0,0 +1,123 @@ +/** A scoped state that wraps several Jotai atoms together, creating an isolated state + * context. + */ +import type { WritableAtom } from "jotai"; +import { ReactNode, useEffect, useMemo, useRef } from "react"; +import h from "@macrostrat/hyper"; + +import { createIsolation } from "jotai-scope"; + +export function createScopedStore(): StateIsolation { + /** A typed wrapper around Jotai-Scope's createIsolation function */ + return enhanceJotaiScope(createIsolation()); +} + +interface JotaiScope { + Provider: (props: { + store?: any; + initialValues?: AtomMap; + children: ReactNode; + }) => ReactNode; + useStore: () => any; + useAtom: any; + useAtomValue: any; + useSetAtom: any; +} + +interface StateIsolation extends JotaiScope { + Provider: (props: ProviderProps) => ReactNode; + useAtomValueIfExists: (atom: WritableAtom) => T | null; +} + +export type AtomMap = [WritableAtom, any][]; + +type ProviderProps = { + children: ReactNode; + atoms?: AtomMap; + keepUpdated?: boolean; + inherit?: boolean; +}; + +function enhanceJotaiScope(scope: JotaiScope): StateIsolation { + /** Enhance a Jotai scope with more sophisticated Provider */ + return { + ...scope, + Provider: (props: ProviderProps): ReactNode => + h(ScopedProvider, { ...props, scope }) as ReactNode, + useAtomValueIfExists: function ( + atom: WritableAtom, + ): T | null { + /** Like useAtomValue, but returns null if no provider is found */ + try { + return scope.useAtomValue(atom); + } catch (e) { + // No provider found + return null; + } + }, + }; +} + +function ScopedProvider({ + scope, + children, + atoms, + keepUpdated = false, + inherit = false, +}: ProviderProps & { scope: JotaiScope }) { + // Always use the same store instance in this tree + const store = useStore(scope, inherit); + if (store != null) { + // This store has already been provided from a parent + return children; + } + return h(scope.Provider, { store: null, initialValues: atoms }, [ + h.if(keepUpdated)(AtomUpdater, { atoms, scope }), + children, + ]); +} + +function useStore(scope: JotaiScope, inherit: boolean = true) { + /** A scoped provider for a store */ + if (!inherit) { + return null; + } + try { + return scope.useStore(); + } catch (e) { + return null; + } +} + +function AtomUpdater({ + scope, + atoms, +}: { + scope: JotaiScope; + atoms: [WritableAtom, any][]; +}) { + /** + * A generic updater to sync Jotai atoms with state passed as props. + * Useful for scoped providers where state needs to be synced outside + * the current context. + */ + /** TODO: this is an awkward way to keep atoms updated */ + // The scoped store + const store = scope.useStore(); + + // Warn on atoms changing length + const prevLengthRef = useRef(atoms.length); + useEffect(() => { + if (prevLengthRef.current !== atoms.length) { + console.error("Error: number of atoms in ScopedAtomUpdater has changed."); + prevLengthRef.current = atoms.length; + } + }, [atoms.length]); + + for (const [atom, value] of atoms) { + useEffect(() => { + store.set(atom, value); + }, [store, value]); + } + return null; +} diff --git a/packages/data-provider/CHANGELOG.md b/packages/data-provider/CHANGELOG.md new file mode 100644 index 00000000..3fe6e88f --- /dev/null +++ b/packages/data-provider/CHANGELOG.md @@ -0,0 +1,6 @@ +# Changelog + +## [1.0.0] - 2026-01-31 + +Moved `MacrostratDataProvider` and data fetchers from `@macrostrat/column-views` +for better modularity. diff --git a/packages/data-provider/README.md b/packages/data-provider/README.md new file mode 100644 index 00000000..47a893f9 --- /dev/null +++ b/packages/data-provider/README.md @@ -0,0 +1,4 @@ +# Data provider + +Common utilities and handlers for data fetching and caching from Macrostrat's +APIs. diff --git a/packages/data-provider/package.json b/packages/data-provider/package.json new file mode 100644 index 00000000..6367cafd --- /dev/null +++ b/packages/data-provider/package.json @@ -0,0 +1,57 @@ +{ + "name": "@macrostrat/data-provider", + "version": "1.0.0", + "description": "Data views for Macrostrat stratigraphic columns", + "repository": { + "type": "git", + "url": "https://github.com/UW-Macrostrat/web-components.git", + "directory": "packages/data-provider" + }, + "license": "MIT", + "type": "module", + "source": "src/index.ts", + "main": "dist/index.js", + "node": "dist/index.cjs", + "types": "dist/index.d.ts", + "files": [ + "src", + "dist" + ], + "sideEffects": false, + "exports": { + ".": { + "source": "./src/index.ts", + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.ts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "scripts": { + "build": "bundle-library ." + }, + "peerDependencies": { + "react": "^18.0.0||^19.0.0" + }, + "dependencies": { + "@macrostrat/api-types": "workspace:^", + "@macrostrat/hyper": "^3.0.6", + "@macrostrat/ui-components": "workspace:^", + "@types/topojson-client": "^3.1.5", + "cross-fetch": "^4.1.0", + "d3-geo": "^3.1.1", + "topojson-client": "^3.1.0", + "zustand": "^5.0.3" + }, + "devDependencies": { + "@macrostrat/web-components-bundler": "workspace:*" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/column-views/src/data-provider/fetch.ts b/packages/data-provider/src/fetch.ts similarity index 86% rename from packages/column-views/src/data-provider/fetch.ts rename to packages/data-provider/src/fetch.ts index 93a2404e..55d32326 100644 --- a/packages/column-views/src/data-provider/fetch.ts +++ b/packages/data-provider/src/fetch.ts @@ -5,11 +5,7 @@ import { StratName, UnitLong, } from "@macrostrat/api-types"; -import { - addQueryString, - joinURL, - useAPIResult, -} from "@macrostrat/ui-components"; +import { addQueryString, joinURL } from "@macrostrat/ui-components"; import crossFetch from "cross-fetch"; import { feature } from "topojson-client"; import { geoArea } from "d3-geo"; @@ -24,12 +20,16 @@ const defaultFetch = createScopedFetch("https://macrostrat.org/api/v2"); export type ColumnStatusCode = "in process" | "active" | "obsolete"; -export interface ColumnFetchOptions { +interface FetchBaseOptions { + // The fetch implementation to use + fetch?: (url: string, options?: RequestInit) => Promise; +} + +export interface ColumnFetchOptions extends FetchBaseOptions { apiBaseURL?: string; projectID?: number; statusCode?: ColumnStatusCode | ColumnStatusCode[]; format?: "geojson" | "topojson" | "geojson_bare"; - fetch?: any; } export async function fetchAllColumns( @@ -53,7 +53,9 @@ export async function fetchAllColumns( } else if (statusCode != null) { _statusCode = statusCode; } - args.statusCode = _statusCode; + if (_statusCode != null) { + args.status_code = _statusCode; + } if (projectID == null) { args = { ...args, all: true }; @@ -83,26 +85,6 @@ export async function fetchAllColumns( return postProcessColumns(columnProcessors[format](data)); } -export function useColumnFeatures({ - apiRoute = "/columns", - status_code, - project_id, - format = "geojson", -}) { - let all: boolean = undefined; - if (status_code == null && project_id == null) { - all = true; - } - - const processor = columnProcessors[format]; - - return useAPIResult( - apiRoute, - { format, all, status_code, project_id }, - processor, - ); -} - function processGeoJSON(res) { return processGeoJSONBare(res?.success?.data); } @@ -171,15 +153,17 @@ async function unwrapResponse(res) { return resData["success"]["data"]; } -export async function fetchLithologies(fetch = defaultFetch) { +export async function fetchLithologies(opts: FetchBaseOptions = {}) { + const { fetch = defaultFetch } = opts; const res = await fetch("/defs/lithologies?all"); return await unwrapResponse(res); } export async function fetchIntervals( timescaleID: number | null, - fetch = defaultFetch, + opts: FetchBaseOptions = {}, ) { + const { fetch = defaultFetch } = opts; let url = `/defs/intervals`; if (timescaleID != null) { url += `?timescale_id=${timescaleID}`; @@ -190,15 +174,17 @@ export async function fetchIntervals( return await unwrapResponse(res); } -export async function fetchEnvironments(fetch = defaultFetch) { +export async function fetchEnvironments(opts: FetchBaseOptions = {}) { + const { fetch = defaultFetch } = opts; const res = await fetch("/defs/environments?all"); return await unwrapResponse(res); } export async function fetchRefs( refs: number[], - fetch = defaultFetch, + opts: FetchBaseOptions = {}, ): Promise { + const { fetch = defaultFetch } = opts; let url = `/defs/refs`; if (refs.length == 0) { return []; @@ -210,8 +196,9 @@ export async function fetchRefs( export async function fetchStratNames( names: number[], - fetch = defaultFetch, + opts: FetchBaseOptions = {}, ): Promise { + const { fetch = defaultFetch } = opts; let url = `/defs/strat_names`; if (names.length == 0) { return []; @@ -228,8 +215,9 @@ export type ColumnData = { export async function fetchUnits( columns: number[], - fetch = defaultFetch, + opts: FetchBaseOptions = {}, ): Promise { + const { fetch = defaultFetch } = opts; const params = new URLSearchParams(); params.append("response", "long"); @@ -263,8 +251,9 @@ export async function fetchUnits( export async function fetchColumnUnits( col_id: number, - fetch = defaultFetch, + opts: FetchBaseOptions = {}, ): Promise { + const { fetch = defaultFetch } = opts; const params = new URLSearchParams(); params.append("response", "long"); params.append("col_id", col_id.toString()); diff --git a/packages/data-provider/src/index.ts b/packages/data-provider/src/index.ts new file mode 100644 index 00000000..6910ac93 --- /dev/null +++ b/packages/data-provider/src/index.ts @@ -0,0 +1,2 @@ +export * from "./fetch"; +export * from "./provider"; diff --git a/packages/column-views/src/data-provider/base.ts b/packages/data-provider/src/provider.ts similarity index 91% rename from packages/column-views/src/data-provider/base.ts rename to packages/data-provider/src/provider.ts index 78493b1e..a9cae942 100644 --- a/packages/column-views/src/data-provider/base.ts +++ b/packages/data-provider/src/provider.ts @@ -21,10 +21,8 @@ import { type ColumnStatusCode, } from "./fetch"; import { APIProvider } from "@macrostrat/ui-components"; -import { ColumnProvider } from "@macrostrat/column-components"; -import { ReactNode } from "react"; -import { useMacrostratColumnData } from "./store"; +import type { ReactNode } from "react"; export interface MacrostratDataProviderProps { baseURL: string; @@ -107,7 +105,7 @@ function createRefsSlice(set, get) { if (missing.length == 0) { return ids.map((id) => refs.get(id)); } - const data = await fetchRefs(missing, fetch); + const data = await fetchRefs(missing, { fetch }); if (data == null) return []; for (const d of data) { refs.set(d.ref_id, d); @@ -165,7 +163,7 @@ function createLithologiesSlice(set, get) { const { lithologies, fetch } = get(); let lithMap = lithologies; if (lithMap == null) { - const data = await fetchLithologies(fetch); + const data = await fetchLithologies({ fetch }); if (data == null) return; lithMap = new Map(data.map((d) => [d.lith_id, d])); set({ lithologies: lithMap }); @@ -184,7 +182,7 @@ function createEnvironmentsSlice(set, get) { const { environments, fetch } = get(); let envMap = environments; if (envMap == null) { - const data = await fetchEnvironments(fetch); + const data = await fetchEnvironments({ fetch }); if (data == null) return []; envMap = new Map(data.map((d) => [d.environ_id, d])); set({ environments: envMap }); @@ -204,7 +202,7 @@ function createIntervalsSlice(set, get) { let _intervals = intervals; if (intervals == null || !includesTimescale(intervals, timescaleID)) { // Fetch the intervals - const data = await fetchIntervals(timescaleID, fetch); + const data = await fetchIntervals(timescaleID, { fetch }); if (data == null) { return []; } @@ -243,7 +241,7 @@ function createStratNamesSlice(set, get) { } } if (stratNamesToLoad.length > 0) { - const data = await fetchStratNames(stratNamesToLoad, fetch); + const data = await fetchStratNames(stratNamesToLoad, { fetch }); if (data == null) return stratNamesAlreadyLoaded; for (const d of data) { nameMap.set(d.strat_name_id, d); @@ -255,6 +253,11 @@ function createStratNamesSlice(set, get) { }; } +export function useStratNames(ids: number[] | null) { + const stratNames = useMemo(() => ids, ids); + return useMacrostratData("strat_names", stratNames); +} + function includesTimescale(intervals: Map, timescaleID: number) { if (intervals == null) return false; if (timescaleID == null) return true; @@ -426,36 +429,6 @@ export function MacrostratAPIProvider({ ); } -export function MacrostratColumnProvider(props) { - /** A column provider specialized the Macrostrat API. Maps more - * generic concepts to Macrostrat-specific ones. - */ - - const { axisType } = useMacrostratColumnData(); - const { units, domain, pixelScale, scale, children } = props; - return h( - ColumnProvider, - { - axisType, - divisions: units, - range: domain, - pixelsPerMeter: pixelScale, - scale, - }, - children, - ); -} - -/** This is now a legacy provider */ -export function LithologiesProvider({ children }) { - useEffect(() => { - console.warn( - "LithologiesProvider is deprecated. Replace with MacrostratDataProvider", - ); - }, []); - return children; -} - export function useLithologies() { const getLithologies = useMacrostratStore((s) => s.getLithologies); const lithologies = useMacrostratStore((s) => s.lithologies); diff --git a/scripts/publish-helpers/status.ts b/scripts/publish-helpers/status.ts index 456befae..cd7e54ea 100644 --- a/scripts/publish-helpers/status.ts +++ b/scripts/publish-helpers/status.ts @@ -179,8 +179,10 @@ async function packageVersionExistsInRegistry(pkg): Promise { let msg = chalk.bold(moduleString(pkg)); // Show last version - const lastVersion: string | null = - info.versions[info.versions.length - 1] ?? null; + let lastVersion: string | null = null; + if (info != null && info.versions != null && info.versions.length > 0) { + lastVersion = info.versions[info.versions.length - 1]; + } let hasChanges: boolean | null = null; if (lastVersion != null) { diff --git a/tsconfig.json b/tsconfig.json index 4143b255..6b06d59f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,10 @@ { "extends": "./tsconfig.base.json", "compilerOptions": { - "types": ["vite/client", "./global.d.ts"] + "types": ["vite/client", "./global.d.ts"], + "paths": { + "@macrostrat/*": ["./packages/*/src/index.ts"] + } }, "include": ["./packages/**/src/*.ts"] } diff --git a/yarn.lock b/yarn.lock index 91b6c94f..b1959825 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1220,6 +1220,7 @@ __metadata: "@macrostrat/color-utils": "workspace:^" "@macrostrat/column-components": "workspace:^" "@macrostrat/data-components": "workspace:^" + "@macrostrat/data-provider": "workspace:^" "@macrostrat/hyper": "npm:^3.0.6" "@macrostrat/map-interface": "workspace:^" "@macrostrat/map-styles": "workspace:^" @@ -1294,12 +1295,32 @@ __metadata: classnames: "npm:^2.5.1" cross-fetch: "npm:^4.1.0" d3-array: "npm:^3.2.4" + jotai: "npm:^2.17.0" + jotai-scope: "npm:^0.10.0" mapbox-gl: "npm:^2.7.0||^3.13.0" peerDependencies: react: ^18.0.0||^19.0.0 languageName: unknown linkType: soft +"@macrostrat/data-provider@workspace:^, @macrostrat/data-provider@workspace:packages/data-provider": + version: 0.0.0-use.local + resolution: "@macrostrat/data-provider@workspace:packages/data-provider" + dependencies: + "@macrostrat/api-types": "workspace:^" + "@macrostrat/hyper": "npm:^3.0.6" + "@macrostrat/ui-components": "workspace:^" + "@macrostrat/web-components-bundler": "workspace:*" + "@types/topojson-client": "npm:^3.1.5" + cross-fetch: "npm:^4.1.0" + d3-geo: "npm:^3.1.1" + topojson-client: "npm:^3.1.0" + zustand: "npm:^5.0.3" + peerDependencies: + react: ^18.0.0||^19.0.0 + languageName: unknown + linkType: soft + "@macrostrat/data-sheet@workspace:^, @macrostrat/data-sheet@workspace:packages/data-sheet": version: 0.0.0-use.local resolution: "@macrostrat/data-sheet@workspace:packages/data-sheet" @@ -7435,6 +7456,27 @@ __metadata: languageName: node linkType: hard +"jotai@npm:^2.17.0": + version: 2.17.0 + resolution: "jotai@npm:2.17.0" + peerDependencies: + "@babel/core": ">=7.0.0" + "@babel/template": ">=7.0.0" + "@types/react": ">=17.0.0" + react: ">=17.0.0" + peerDependenciesMeta: + "@babel/core": + optional: true + "@babel/template": + optional: true + "@types/react": + optional: true + react: + optional: true + checksum: 10c0/cc6646c792881fa52cc8bef0b05152110af78a167b7ee10ee1cd2635904ed5175a5d00ba255d9a1abe4954549da48e6e963c95cb6deaa069a706cea8ef141bdb + languageName: node + linkType: hard + "js-tokens@npm:^3.0.0 || ^4.0.0, js-tokens@npm:^4.0.0": version: 4.0.0 resolution: "js-tokens@npm:4.0.0"