diff --git a/package.json b/package.json index 611dab871..a5faa6d29 100644 --- a/package.json +++ b/package.json @@ -123,6 +123,7 @@ "history": "^5.3.0", "immutability-helper": "^3.1.1", "jose": "^5.1.2", + "jotai": "^2.15.1", "mapbox-gl": "^3.13.0", "new-github-issue-url": "^1.0.0", "part-regex": "^0.1.2", diff --git a/pages/columns/+Page.ts b/pages/columns/+Page.ts index 2ee4a5ebd..f8bd69b2b 100644 --- a/pages/columns/+Page.ts +++ b/pages/columns/+Page.ts @@ -1,371 +1,411 @@ import hyper from "@macrostrat/hyper"; import styles from "./main.module.sass"; -import React, { useState, useEffect, useRef, useMemo } from "react"; +import { useMemo, Suspense } from "react"; import { ContentPage } from "~/layouts"; import { Link, DevLinkButton, PageBreadcrumbs } from "~/components"; -import { FlexRow, LithologyTag } from "~/components/lex/tag"; +import { LithologyTag } from "~/components/lex/tag"; import { Footer, SearchBar } from "~/components/general"; -import { getGroupedColumns } from "./grouped-cols"; +import { + ColumnFilterOptions, + ColumnGroup, + getGroupedColumns, +} from "./grouped-cols"; +import { ErrorBoundary, FlexRow } from "@macrostrat/ui-components"; -import { AnchorButton, ButtonGroup, Switch, Popover } from "@blueprintjs/core"; +import { + AnchorButton, + ButtonGroup, + Switch, + Popover, + Spinner, +} from "@blueprintjs/core"; import { Tag, Icon } from "@blueprintjs/core"; import { useData } from "vike-react/useData"; import { ClientOnly } from "vike-react/ClientOnly"; import { navigate } from "vike/client/router"; -import { LexSelection } from "@macrostrat/form-components"; -import { postgrestPrefix } from "@macrostrat-web/settings"; -import { - useAPIResult, - PostgRESTInfiniteScrollView, -} from "@macrostrat/ui-components"; +import { postgrest } from "~/_providers"; -const h = hyper.styled(styles); +/** + * Jotai provides a composable approach to state management + * that can be used to add behaviors iteratively + */ -export function Page(props) { - return h(ColumnListPage, props); -} +import { atom, useAtom, useAtomValue, useSetAtom, Provider } from "jotai"; +import { unwrap, useHydrateAtoms } from "jotai/utils"; +import { debounce } from "underscore"; + +const h = hyper.styled(styles); function ColumnMapContainer(props) { return h( ClientOnly, { - load: () => import("./map.client").then((d) => d.ColumnsMapContainer), - fallback: h("div.loading", "Loading map..."), - deps: [props.columnIDs, props.projectID, props.hideColumns], + load: () => import("./map.client").then((d) => d.ColumnMapContainer), + fallback: h(Spinner), + deps: [props.columnIDs, props.projectID], }, (component) => h(component, props) ); } -function ColumnListPage({ title = "Columns", linkPrefix = "/" }) { - const { allColumnGroups, project } = useData(); +type ColumnFilterKey = "liths" | "stratNames" | "intervals"; - const [columnGroups, setColumnGroups] = useState(null); - const [loading, setLoading] = useState(false); - const [extraParams, setExtraParams] = useState({}); +type ColumnFilterDef = { + type: ColumnFilterKey; + identifier: number; + name: string; + color: string; +}; - const [columnInput, setColumnInput] = useState(""); - const [showEmpty, setShowEmpty] = useState(true); - const [filteredInput, setFilteredInput] = useState(""); - const [showInProcess, setShowInProcess] = useState(true); +const columnFilterAtom = atom([]); - const [selectedLiths, setSelectedLiths] = useState(null); - const [selectedUnits, setSelectedUnits] = useState(null); - const [selectedStratNames, setSelectedStratNames] = useState(null); - const [selectedIntervals, setSelectedIntervals] = useState(null); +const addFilterAtom = atom(null, (_, set, data: ColumnFilterDef) => { + set(columnFilterAtom, (value) => { + return [...value, data]; + }); + set(inputTextAtom, ""); // Clear input text when adding a filter +}); - const isEmpty = Object.keys(extraParams).length === 0; - const filteredGroups = isEmpty ? allColumnGroups : columnGroups ?? []; +const showEmptyAtom = atom(true); +const showInProcessAtom = atom(false); - const selectedItems = - selectedLiths || selectedUnits || selectedStratNames || selectedIntervals; +const inputTextAtom = atom(""); - useEffect(() => { - const params: any = {}; +const suggestedFiltersFetchAtom = atom(async (get) => { + const inputText = get(inputTextAtom); + if (inputText.length < 3) return []; + return await fetchFilterItems(inputText); +}); - if (filteredInput.length >= 3) { - params.name = `ilike.%${filteredInput}%`; - } - if (!showEmpty) { - params.empty = `is.false`; - } - if (!showInProcess) { - params.status_code = "eq.active"; - } - if (selectedLiths) { - params.liths = `cs.[${selectedLiths.lex_id}]`; - } - if (selectedUnits) { - params.units = `cs.[${selectedUnits.lex_id}]`; - } - if (selectedStratNames) { - params.strat_names = `cs.[${selectedStratNames.lex_id}]`; - } - if (selectedIntervals) { - params.intervals = `cs.[${selectedIntervals.lex_id}]`; - } +const suggestedFiltersAtom = unwrap(suggestedFiltersFetchAtom, (prev) => { + return prev ?? []; +}); - setExtraParams(params); - }, [ - filteredInput, - showEmpty, - showInProcess, - selectedLiths, - selectedUnits, - selectedStratNames, - selectedIntervals, - ]); +const filterParamsAtom = atom((get) => { + const filters = get(columnFilterAtom); + const showEmpty = get(showEmptyAtom); + const showInProcess = get(showInProcessAtom); + const projectID = get(projectIDAtom); - // set filtered input - useEffect(() => { - const prevLength = prevInputLengthRef.current; + const params = buildParamsFromFilters(filters); - if (columnInput.length >= 3) { - setFilteredInput(columnInput); - } else if (prevLength >= 3 && columnInput.length === 2) { - setFilteredInput(""); - } + if (projectID == null) return null; - prevInputLengthRef.current = columnInput.length; - }, [columnInput, showEmpty, showInProcess]); + params.project_id = projectID; - const prevInputLengthRef = useRef(columnInput.length); + if (!showEmpty) { + params.empty = false; + } + if (!showInProcess) { + params.status_code = "active"; + } - useEffect(() => { - if (!isEmpty) { - setLoading(true); - getGroupedColumns(project?.project_id, extraParams) - .then((groups) => setColumnGroups(groups)) - .finally(() => setLoading(false)); - } - }, [project?.project_id, extraParams]); + if (Object.keys(params).length === 0) { + return null; + } - const columnIDs = useMemo(() => { - return filteredGroups?.flatMap((item) => - item.columns.map((col) => col.col_id) - ); - }, [filteredGroups]); + return params as ColumnFilterOptions; +}); - const handleInputChange = (value, target) => { - setColumnInput(value.toLowerCase()); - }; +const projectIDAtom = atom(); - const handleLexclick = (data) => { - if (data.type == "strat name") setSelectedLiths(data); - if (data.type == "unit") setSelectedUnits(data); - if (data.type == "lithology") setSelectedLiths(data); - if (data.type == "interval") setSelectedIntervals(data); - setColumnInput(""); - setFilteredInput(""); - }; +const fetchDataAtom = atom(async (get) => { + const filterParams = get(filterParamsAtom); + return await instrumentResult(getGroupedColumns(filterParams)); +}); - function LexCard({ data }) { - return h( - FlexRow, - { - alignItems: "center", - width: "fit-content", - gap: ".5em", - className: "lith-tag", - onClick: () => handleLexclick(data), - }, - [ - h(LithologyTag, { data: { name: data.name, color: data.color } }), - h("p.label", data.type), - ] - ); - } +const initialDataAtom = atom(); - const res = useAPIResult( - postgrestPrefix + "/col_filter?name=ilike.*" + filteredInput + "*" +const downloadedGroupsAtom = unwrap(fetchDataAtom, (prev) => { + return { + data: prev?.data ?? null, + error: prev?.error ?? null, + loading: true, + }; +}); + +const isLoadingAtom = atom((get) => { + const downloaded = get(downloadedGroupsAtom); + return downloaded.loading; +}); + +const filteredGroupsAtom = atom((get) => { + /** Apply client-side text filtering to column names */ + const result = get(downloadedGroupsAtom).data ?? get(initialDataAtom) ?? []; + const inputText = get(inputTextAtom).toLowerCase(); + if (inputText.length < 3) return result; + + return result + .map((group) => { + const matchingColumns = group.columns.filter((col) => + col.col_name.toLowerCase().includes(inputText) + ); + return { + ...group, + columns: matchingColumns, + }; + }) + .filter((group) => group.columns.length > 0); +}); + +function ColumnPageProvider({ children, projectID, initialData }) { + return h( + ErrorBoundary, + h(Provider, h(InitialStateProvider, { projectID, initialData }, children)) ); +} + +function InitialStateProvider({ children, projectID, initialData }) { + useHydrateAtoms([ + [projectIDAtom, projectID], + [initialDataAtom, initialData], + ]); + return children; +} - const suggestData = res?.slice(0, 5); - - return h("div.column-list-page", [ - h(ContentPage, [ - h("div.flex-row", [ - h("div.main", [ - h("div", [ - h(PageBreadcrumbs, { showLogo: true }), - h("div.filters", [ - h(SearchBar, { - placeholder: "Search columns...", - onChange: handleInputChange, - className: "search-bar", - value: columnInput, - }), - h( - Popover, - { - content: h.if(!selectedItems && suggestData?.length > 0)( - "div.suggested-items", - suggestData?.map((item) => h(LexCard, { data: item })) +export function Page({ title = "Columns", linkPrefix = "/" }) { + const { project_id, allColumnGroups } = useData(); + return h( + ColumnPageProvider, + { projectID: project_id, initialData: allColumnGroups }, + h("div.column-list-page", [ + h(Suspense, [ + h(ContentPage, [ + h("div.flex-row", [ + h("div.main", [ + h("div", [ + h(PageBreadcrumbs, { showLogo: true }), + h(FilterManager), + h(LexFilters), + ]), + h(ColumnDataArea, { linkPrefix }), + ]), + h("div.sidebar", [ + h("div.sidebar-content", [ + h(ButtonGroup, { vertical: true, large: true }, [ + h( + AnchorButton, + { href: "/projects", minimal: true }, + "Projects" ), - isOpen: filteredInput.length >= 3, - position: "right", - }, - h("div") - ), - h("div.switches", [ - h(Switch, { - checked: showEmpty, - label: "Show empty", - onChange: () => setShowEmpty(!showEmpty), - }), - h(Switch, { - checked: showInProcess, - label: "Show in process", - onChange: () => setShowInProcess(!showInProcess), + h( + DevLinkButton, + { href: "/columns/correlation" }, + "Correlation chart" + ), + ]), + h(ColumnMapOuter, { + projectID: project_id, }), ]), ]), - h(LexFilters, { - selectedLiths, - setSelectedLiths, - selectedUnits, - setSelectedUnits, - selectedStratNames, - setSelectedStratNames, - selectedIntervals, - setSelectedIntervals, - }), - ]), - h.if(!loading)( - "div.column-groups", - filteredGroups?.map((d) => - h(ColumnGroup, { - data: d, - key: d.id, - linkPrefix, - showEmpty, - }) - ) - ), - h.if(columnGroups?.length == 0 && !loading)( - "div.empty", - "No columns found" - ), - h.if(loading)("div.loading", "Loading columns..."), - ]), - h("div.sidebar", [ - h("div.sidebar-content", [ - h(ButtonGroup, { vertical: true, large: true }, [ - h(AnchorButton, { href: "/projects", minimal: true }, "Projects"), - h( - DevLinkButton, - { href: "/columns/correlation" }, - "Correlation chart" - ), - ]), - h(ColumnMapContainer, { - columnIDs, - projectID: project?.project_id, - className: "column-map-container", - }), ]), ]), + h(Footer), ]), + ]) + ); +} + +function ColumnMapOuter({ projectID }) { + const filteredGroups = useAtomValue(filteredGroupsAtom); + + const columnIDs = useMemo(() => { + if (filteredGroups == null) return null; + return filteredGroups.flatMap((item) => + item.columns.map((col) => col.col_id) + ); + }, [filteredGroups]); + + return h(ColumnMapContainer, { + columnIDs, + projectID, + className: "column-map-container", + }); +} + +function ColumnDataArea({ linkPrefix }) { + const showEmpty = useAtomValue(showEmptyAtom); + const data = useAtomValue(filteredGroupsAtom); + const isLoading = useAtomValue(isLoadingAtom); + + return h( + ContentArea, + { isLoading }, + h( + "div.column-groups", + data.map((d) => + h(ColumnGroup, { + data: d, + key: d.id, + linkPrefix, + showEmpty, + }) + ) + ) + ); +} + +function ContentArea({ isLoading, children }) { + return h("div.content-area", [ + children, + isLoading && + h("div.loading-overlay", [ + h(Spinner, { className: "loading-spinner", size: 50 }), + ]), + ]); +} + +function FilterManager() { + const [showEmpty, setShowEmpty] = useAtom(showEmptyAtom); + const [showInProcess, setShowInProcess] = useAtom(showInProcessAtom); + const [columnInput, setColumnInput] = useAtom(inputTextAtom); + + const suggestedFilters = useAtomValue(suggestedFiltersAtom) ?? []; + + return h("div.filters", [ + h(SearchBar, { + placeholder: "Search columns...", + onChange: setColumnInput, + className: "search-bar", + value: columnInput, + }), + h( + Popover, + { + content: h( + "div.suggested-items", + suggestedFilters.map((d) => + h(LexCard, { data: d, key: d.type + d.lex_id }) + ) + ), + isOpen: suggestedFilters.length > 0, + position: "right", + usePortal: false, + autoFocus: false, + }, + h("div") + ), + h("div.switches", [ + h(Switch, { + checked: showEmpty, + label: "Show empty", + onChange: () => setShowEmpty(!showEmpty), + }), + h(Switch, { + checked: showInProcess, + label: "Show in process", + onChange: () => setShowInProcess(!showInProcess), + }), ]), - h(Footer), ]); } +function LexCard({ data }) { + const addFilter = useSetAtom(addFilterAtom); + + const handleLexClick = (data: { type: string; lex_id: number }) => { + const filterKey = filterKeyFromType(data.type); + const obj = { + type: filterKey, + identifier: data.lex_id, + name: data.name, + color: data.color, + }; + addFilter(obj); + }; + + return h( + FlexRow, + { + alignItems: "center", + width: "fit-content", + gap: ".5em", + className: "lith-tag", + onClick: () => handleLexClick(data), + }, + [ + h(LithologyTag, { data: { name: data.name, color: data.color } }), + h("p.label", data.type), + ] + ); +} + function ColumnGroup({ data, linkPrefix }) { - const [isOpen, setIsOpen] = useState(false); const filteredColumns = data.columns; if (filteredColumns?.length === 0) return null; const { name } = data; - return h( - "div", - { className: "column-group", onClick: () => setIsOpen(!isOpen) }, - [ - h("div.column-group-header", [ - h(Link, { href: `/columns/groups/${data.id}`, target: "_self" }, [ - h( - "h2.column-group-name", - name + " (Group #" + filteredColumns[0].col_group_id + ")" - ), - ]), + return h("div.column-group", [ + h("div.column-group-header", [ + h(Link, { href: `/columns/groups/${data.id}`, target: "_self" }, [ + h( + "h2.column-group-name", + name + " (Group #" + filteredColumns[0].col_group_id + ")" + ), ]), - h("div.column-list", [ - h("table.column-table", [ - h("thead.column-row.column-header", [ - h("tr", [ - h("th.col-id", "ID"), - h("th.col-name", "Name"), - h("th.col-status", "Status"), - ]), - ]), - h("tbody", [ - filteredColumns.map((data) => h(ColumnItem, { data, linkPrefix })), + ]), + h("div.column-list", [ + h("table.column-table", [ + h("thead.column-row.column-header", [ + h("tr", [ + h("th.col-id", "ID"), + h("th.col-name", "Name"), + h("th.col-status", "Status"), ]), ]), + h("tbody", [ + filteredColumns.map((data) => h(ColumnItem, { data, linkPrefix })), + ]), ]), - ] - ); + ]), + ]); } -const ColumnItem = React.memo( - function ColumnItem({ data, linkPrefix = "/" }) { - const { col_id, name, units } = data; +function ColumnItem({ data, linkPrefix = "/" }) { + const { col_id, col_name, units } = data; - const unitsText = units?.length > 0 ? `${units?.length} units` : "empty"; + const unitsText = units?.length > 0 ? `${units?.length} units` : "empty"; - const href = linkPrefix + `columns/${col_id}`; - return h( - "tr.column-row", - { - onClick() { - navigate(href); - }, + const href = linkPrefix + `columns/${col_id}`; + return h( + "tr.column-row", + { + onClick() { + navigate(href); }, - [ - h("td.col-id", h("code.bp5-code", col_id)), - h("td.col-name", h("a", { href }, name)), - h("td.col-status", [ - data.status_code === "in process" && - h( - Tag, - { minimal: true, color: "lightgreen", size: "small" }, - "in process" - ), - " ", + }, + [ + h("td.col-id", h("code.bp5-code", col_id)), + h("td.col-name", h("a", { href }, col_name)), + h("td.col-status", [ + data.status_code === "in process" && h( Tag, - { - minimal: true, - size: "small", - color: units?.length === 0 ? "orange" : "dodgerblue", - }, - unitsText + { minimal: true, color: "lightgreen", size: "small" }, + "in process" ), - ]), - ] - ); - }, - (prevProps, nextProps) => { - return ( - prevProps.data.col_id === nextProps.data.col_id && - prevProps.data.col_name === nextProps.data.col_name && - prevProps.data.status === nextProps.data.status && - prevProps.data.t_units === nextProps.data.t_units && - prevProps.linkPrefix === nextProps.linkPrefix - ); - } -); - -function LexFilters({ - selectedLiths, - setSelectedLiths, - selectedUnits, - setSelectedUnits, - selectedStratNames, - setSelectedStratNames, - selectedIntervals, - setSelectedIntervals, -}) { - const show = - selectedLiths || selectedStratNames || selectedUnits || selectedIntervals; - - if (!show) return null; - - const { type, lex_id } = - selectedLiths ?? selectedStratNames ?? selectedUnits ?? selectedIntervals; - - const route = - type === "lithology" - ? "lithologies" - : type === "strat name" - ? "strat-names" - : type === "interval" - ? "intervals" - : "units"; + " ", + h( + Tag, + { + minimal: true, + size: "small", + color: units?.length === 0 ? "orange" : "dodgerblue", + }, + unitsText + ), + ]), + ] + ); +} +function LexFilters() { + const filters = useAtomValue(columnFilterAtom); + if (filters.length == 0) return null; return h("div.lex-filters", [ h( FlexRow, @@ -375,25 +415,125 @@ function LexFilters({ }, [ h("p.filter", "Filtering columns by "), - h(LithologyTag, { - href: `/lex/${route}/${lex_id}`, - data: - selectedLiths ?? - selectedStratNames ?? - selectedUnits ?? - selectedIntervals, - }), - h(Icon, { - className: "close-btn", - icon: "cross", - onClick: () => { - setSelectedLiths(null); - setSelectedStratNames(null); - setSelectedUnits(null); - setSelectedIntervals(null); - }, - }), + ...filters.map((filter) => + h(ColumnFilterItem, { + data: { + ...filter, + lex_id: filter.identifier, + }, + key: filter.type + filter.identifier, + }) + ), ] ), ]); } + +async function _fetchFilterItems(inputText: string) { + // Fetch filter items from the API based on input text, using the PostgREST client API + const res = postgrest + .from("col_filter") + .select("*") + .ilike("name", `%${inputText}%`) + .limit(5); + + // Todo: add error handling + const { data, error } = await res; + return data ?? []; +} + +const fetchFilterItems = debounce(_fetchFilterItems, 300); + +const clearAllFiltersAtom = atom(null, (get, set) => { + set(columnFilterAtom, []); +}); + +function ColumnFilterItem({ data }: { data: ColumnFilterDef }) { + const { type, identifier } = data; + const route = routeForFilterKey(type); + const clearAllFilters = useSetAtom(clearAllFiltersAtom); + return h("div.lex-filter-item", [ + h(LithologyTag, { + href: `/lex/${route}/${identifier}`, + data, + }), + h(Icon, { + className: "close-btn", + icon: "cross", + onClick: clearAllFilters, + }), + ]); +} + +function routeForFilterKey(key: ColumnFilterKey): string { + switch (key) { + case "liths": + return "lithologies"; + case "stratNames": + return "strat-names"; + case "intervals": + return "intervals"; + } +} + +function filterKeyFromType(type: string): ColumnFilterKey | null { + switch (type) { + case "lithology": + return "liths"; + case "strat name": + return "stratNames"; + case "interval": + return "intervals"; + default: + return null; + } +} + +function paramNameForFilterKey(key: ColumnFilterKey): string { + switch (key) { + case "liths": + return "liths"; + case "stratNames": + return "strat_names"; + case "intervals": + return "intervals"; + } +} + +function buildParamsFromFilters( + filters: ColumnFilterDef[], + // Allow multiple filters per category (not supported in API v2) + allowMultiple = false +): Partial { + const params: Record = {}; + if (filters == null) return params; + let filterData: Partial = {}; + for (const filter of filters) { + const key = paramNameForFilterKey(filter.type); + if (allowMultiple) { + filterData[key] ??= []; + } else { + filterData[key] = []; + } + filterData[key].push(filter.identifier); + } + return filterData; +} + +async function instrumentResult( + promise: Promise +): Promise<{ data: T | null; error: any | null; loading: boolean }> { + try { + return { + data: await promise, + error: null, + loading: false, + }; + } catch (e) { + return { + data: null, + error: e, + loading: false, + }; + } +} diff --git a/pages/columns/+data.ts b/pages/columns/+data.ts index c9dc2d575..23289b92f 100644 --- a/pages/columns/+data.ts +++ b/pages/columns/+data.ts @@ -1,7 +1,7 @@ import { getGroupedColumns } from "./grouped-cols"; export async function data(pageContext) { - // https://v2.macrostrat.org/api/v2/columns?col_id=3&response=lon - const allColumnGroups = await getGroupedColumns(1); - return { allColumnGroups }; + // https://v2.macrostrat.org/api/v2/columns?col_id=3&response=long + const res = await getGroupedColumns({ project_id: 1 }); + return { allColumnGroups: res, project_id: 1 }; } diff --git a/pages/columns/grouped-cols.ts b/pages/columns/grouped-cols.ts index 1345ee416..4b6c62d2b 100644 --- a/pages/columns/grouped-cols.ts +++ b/pages/columns/grouped-cols.ts @@ -1,47 +1,56 @@ -import { fetchAPIData, fetchPGData } from "~/_utils"; +import { fetchAPIV2Result } from "~/_utils"; -export async function getGroupedColumns(project_id: number | null, params?: any) { - // lex filter - const useBase = !params?.liths && !params?.units && !params?.strat_names && !params?.intervals; +interface ColumnResponseShort { + col_id: number; + col_name: string; + col_group: string; + col_group_id: number | null; + project_id: number; + status_code: string; + lat: number; + lng: number; + col_area: number; + col_type: "column" | "section"; + refs: number[]; +} - const columnURL = useBase ? "/col_base" : "/col_data"; +export interface ColumnGroup { + id: number; + name: string; + columns: ColumnResponseShort[]; +} - const pgParams = project_id != null ? { ...params, project_id: `eq.${project_id}` } : params; - - const [columns, groups] = await Promise.all([ - fetchPGData(columnURL, pgParams), - fetchAPIData(`/defs/groups`, { all: true }), - ]); - - if(!columns) { - return null - } +export async function getGroupedColumns(params: ColumnFilterOptions) { + const { data: columns, refs } = await fetchColumns(params); columns.sort((a, b) => a.col_id - b.col_id); - // Group by col_group - // Create a map of column groups - const groupMap = new Map( - groups.map((g) => [ - g.col_group_id, - { name: g.name, id: g.col_group_id, columns: [] }, - ]) - ); - groupMap.set(-1, { - id: -1, - name: "Ungrouped", - columns: [], - }); + const groupMap = new Map(); for (const col of columns) { - const col_group_id = col.col_group_id ?? -1; - const group = groupMap.get(col_group_id); - group.columns.push(col); + // If the column is not part of a group, put it in an "Ungrouped" group + if (col.col_group_id == null) { + if (!groupMap.has(-1)) { + groupMap.set(-1, { + id: -1, + name: "Ungrouped", + columns: [], + }); + } + groupMap.get(-1).columns.push(col); + continue; + } + if (!groupMap.has(col.col_group_id)) { + groupMap.set(col.col_group_id, { + id: col.col_group_id, + name: col.col_group, + columns: [], + }); + } + groupMap.get(col.col_group_id).columns.push(col); } - const groupsArray = Array.from(groupMap.values()).filter( - (g) => g.columns.length > 0 - ); + const groupsArray = Array.from(groupMap.values()); // Sort the groups by id groupsArray.sort((a, b) => { @@ -50,4 +59,50 @@ export async function getGroupedColumns(project_id: number | null, params?: any) }); return groupsArray; -} \ No newline at end of file +} + +export interface ColumnFilterOptions { + project_id: number; + status_code?: string; + empty?: boolean; + strat_names?: number[]; + intervals?: number[]; + liths?: number[]; + nameFuzzyMatch?: string; +} + +async function fetchColumns(opts: ColumnFilterOptions) { + const params = new URLSearchParams(); + + const { project_id } = opts; + + params.append("project_id", project_id.toString()); + + if (opts.status_code) { + params.append("status_code", opts.status_code); + } + + // Empty and name fuzzy match are not supported yet + if (opts.strat_names) { + for (const sn of opts.strat_names) { + params.append("strat_name_id", sn.toString()); + } + } + + if (opts.intervals) { + for (const iv of opts.intervals) { + params.append("int_id", iv.toString()); + } + } + + if (opts.liths) { + for (const lz of opts.liths) { + params.append("lith_id", lz.toString()); + } + } + + return (await fetchAPIV2Result("/columns", params)) as Promise<{ + data: ColumnResponseShort[]; + refs: { [key: number]: string }; + }>; +} diff --git a/pages/columns/main.module.sass b/pages/columns/main.module.sass index 745142e45..63ace022e 100644 --- a/pages/columns/main.module.sass +++ b/pages/columns/main.module.sass @@ -142,7 +142,7 @@ background-color: var(--background-color) width: 100% -.search +.search width: 80% position: relative @@ -158,4 +158,25 @@ margin: 0 .suggested-items - padding: .5em \ No newline at end of file + padding: .5em + +.content-area + position: relative + +.loading-overlay + position: absolute + top: 0 + left: 0 + width: 100% + height: 100% + background-color: var(--background-color) + opacity: 0.5 + display: flex + justify-content: center + align-items: center + z-index: 10 + +.loading-spinner + position: sticky + top: 50% + bottom: 50% diff --git a/pages/columns/map.client.ts b/pages/columns/map.client.ts index 853591d1e..9cc993cd1 100644 --- a/pages/columns/map.client.ts +++ b/pages/columns/map.client.ts @@ -1,29 +1,43 @@ import { ColumnNavigationMap, + MacrostratDataProvider, useMacrostratColumns, } from "@macrostrat/column-views"; import h from "@macrostrat/hyper"; -import { mapboxAccessToken } from "@macrostrat-web/settings"; +import { apiV2Prefix, mapboxAccessToken } from "@macrostrat-web/settings"; import { ErrorBoundary } from "@macrostrat/ui-components"; -import { useMapRef, useMapStyleOperator } from "@macrostrat/mapbox-react"; +import { useMapStyleOperator } from "@macrostrat/mapbox-react"; import mapboxgl from "mapbox-gl"; -export function ColumnsMapContainer(props) { - return h(ErrorBoundary, h(ColumnsMapInner, props)); +export function ColumnMapContainer(props) { + return h( + ErrorBoundary, + h( + MacrostratDataProvider, + { baseURL: apiV2Prefix }, + h(ColumnsMapInner, props) + ) + ); } -function ColumnsMapInner({ columnIDs = null, projectID = null, className, hideColumns }) { - const columnData = useMacrostratColumns(projectID, projectID != null); +function ColumnsMapInner({ + columnIDs = null, + projectID = null, + className, + inProcess = false, +}) { + const columnBaseData = useMacrostratColumns(projectID, inProcess) ?? []; - if(!columnData) { - return h("div", { className }, "Loading map..."); - } + const columnData = columnBaseData.filter((col) => + columnIDs?.includes(col.id) + ); return h( "div", { className }, - h(ColumnNavigationMap, - { + h( + ColumnNavigationMap, + { style: { height: "100%" }, accessToken: mapboxAccessToken, onSelectColumn: (col) => { @@ -31,24 +45,20 @@ function ColumnsMapInner({ columnIDs = null, projectID = null, className, hideCo window.open(`/columns/${col}`, "_self"); } }, - columnIDs, + columns: columnData, projectID, }, - h(FitBounds, { columnData, projectID }) - ), + h(FitBounds, { columnData }) + ) ); } -function FitBounds({ columnData, projectID }) { - if (projectID == 3) { - return - } +function FitBounds({ columnData }) { useMapStyleOperator((map) => { if (!map || columnData.length === 0) return; // Extract coordinates - const coords = columnData - .map(col => col.geometry.coordinates[0][0]); + const coords = columnData.map((col) => col.geometry.coordinates[0][0]); if (coords.length === 0) return; // Build bounds using the first coordinate @@ -61,7 +71,7 @@ function FitBounds({ columnData, projectID }) { padding: 50, duration: 0, }); - }); + }, [columnData]); return null; } diff --git a/src/_utils/fetch-helpers.ts b/src/_utils/fetch-helpers.ts index b7d0cdd39..7ca650f39 100644 --- a/src/_utils/fetch-helpers.ts +++ b/src/_utils/fetch-helpers.ts @@ -1,24 +1,32 @@ import { apiV2Prefix, postgrestPrefix } from "@macrostrat-web/settings"; import fetch from "cross-fetch"; -export async function fetchAPIData(apiURL: string, params: any) { +export async function fetchAPIV2Result(apiURL: string, params: any) { let url = new URL(apiV2Prefix + apiURL); if (params != null) { - url.search = new URLSearchParams(params).toString(); + let p1 = params; + // If we already have a URLSearchParams object, just use it directly + if (!(p1 instanceof URLSearchParams)) { + p1 = new URLSearchParams(params); + } + url.search = p1.toString(); } const res = await fetchWrapper(url.toString()); const res1 = await res?.json(); - return res1?.success?.data || []; + if (res1.error != null) { + throw new Error(res1.error); + } + return res1?.success; +} + +export async function fetchAPIData(apiURL: string, params: any) { + const res = fetchAPIV2Result(apiURL, params); + return res?.data || []; } export async function fetchAPIRefs(apiURL: string, params: any) { - let url = new URL(apiV2Prefix + apiURL); - if (params != null) { - url.search = new URLSearchParams(params).toString(); - } - const res = await fetchWrapper(url.toString()); - const res1 = await res?.json(); - return res1?.success?.refs || []; + const res = await fetchAPIV2Result(apiURL, params); + return res?.refs || []; } export async function fetchPGData(apiURL: string, params: any) { @@ -34,7 +42,7 @@ export async function fetchPGData(apiURL: string, params: any) { function isServer() { return ( typeof window === "undefined" || - typeof process !== "undefined" && process.release?.name === "node" + (typeof process !== "undefined" && process.release?.name === "node") ); } @@ -46,7 +54,9 @@ function fetchWrapper(url: string): Promise { const endTime = performance.now(); const duration = endTime - startTime; console.log( - `Fetching ${url} - status ${response.status} - ${duration.toFixed(2)} ms` + `Fetching ${url} - status ${response.status} - ${duration.toFixed( + 2 + )} ms` ); } return response; diff --git a/yarn.lock b/yarn.lock index e5451db8a..659d09492 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3630,6 +3630,7 @@ __metadata: http-proxy-middleware: "npm:^3.0.3" immutability-helper: "npm:^3.1.1" jose: "npm:^5.1.2" + jotai: "npm:^2.15.1" mapbox-gl: "npm:^3.13.0" new-github-issue-url: "npm:^1.0.0" nodemon: "npm:^3.1.9" @@ -12862,6 +12863,27 @@ __metadata: languageName: node linkType: hard +"jotai@npm:^2.15.1": + version: 2.15.1 + resolution: "jotai@npm:2.15.1" + 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: 10/d346ded947768ba36ca70e8a55262281802bdb7ef2b14bc783a14c6a80c5a7e1bfcda6bf3ba4a747953c73dad8a8d205b2e0f57fa73e436a6188ecade5c6191b + languageName: node + linkType: hard + "jpeg-js@npm:^0.4.1": version: 0.4.4 resolution: "jpeg-js@npm:0.4.4"