diff --git a/packages/column-components/CHANGELOG.md b/packages/column-components/CHANGELOG.md index c2a2e4bcd..8dcf0b28e 100644 --- a/packages/column-components/CHANGELOG.md +++ b/packages/column-components/CHANGELOG.md @@ -4,6 +4,12 @@ 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). +## [1.4.0] - 2025-12-10 + +- Major update to `Note` and note-related components +- Add a `FocusableNoteColumn` mode based on the `EditableNoteColumn` component +- Removed outdated note-related styles and simplified CSS scopes + ## [1.3.1] - 2025-11-28 - Update axis components diff --git a/packages/column-components/package.json b/packages/column-components/package.json index 64661e875..c35375b39 100644 --- a/packages/column-components/package.json +++ b/packages/column-components/package.json @@ -1,6 +1,6 @@ { "name": "@macrostrat/column-components", - "version": "1.3.1", + "version": "1.4.0", "description": "React rendering primitives for stratigraphic columns", "keywords": [ "geology", diff --git a/packages/column-components/src/axis.ts b/packages/column-components/src/axis.ts index 886605e6c..f48d67884 100644 --- a/packages/column-components/src/axis.ts +++ b/packages/column-components/src/axis.ts @@ -57,12 +57,21 @@ export function AgeAxis(props: AgeAxisProps) { let tickValues: number[] = undefined; - let ticks = Math.max(Math.round(pixelHeight / tickSpacing), 2); - if (pixelHeight < 3 * tickSpacing || scale.ticks(2).length < 2) { - // Push ticks towards extrema - const t0 = scale.ticks(4); + let ticks = Math.round(pixelHeight / tickSpacing); + if (pixelHeight < 3 * tickSpacing) { + // Push ticks towards extrema (we need more than 2 to be resolved) + + let t0: number[] = []; + while (t0.length <= 2) { + ticks += 1; + t0 = scale.ticks(ticks); + } - tickValues = [t0[0], t0[t0.length - 1]]; + tickValues = t0; + if (pixelHeight < 2 * tickSpacing) { + // Only show first and last ticks + tickValues = [t0[0], t0[t0.length - 1]]; + } } if (pixelHeight < minTickSpacing) { diff --git a/packages/column-components/src/hyper.ts b/packages/column-components/src/hyper.ts index 03edf15b8..aeaa81ef7 100644 --- a/packages/column-components/src/hyper.ts +++ b/packages/column-components/src/hyper.ts @@ -1,6 +1,11 @@ import { hyperStyled } from "@macrostrat/hyper"; import styles from "./main.module.scss"; +import noteStyles from "./notes/notes.module.sass"; -const h = hyperStyled(styles); +const styles1 = { ...styles, ...noteStyles }; + +const h = hyperStyled(styles1); + +console.log("Styles", styles1); export default h; diff --git a/packages/column-components/src/main.module.scss b/packages/column-components/src/main.module.scss index 5a8e1c232..c1023fec0 100644 --- a/packages/column-components/src/main.module.scss +++ b/packages/column-components/src/main.module.scss @@ -1,44 +1,3 @@ -.column-notes .col-note-label { - cursor: pointer; -} -.column-notes .note-editor.bp5-editable-text { - user-select: none; -} -.column-notes .note-editor.bp5-editable-text:before { - top: 0; - left: 0; - right: 0; - bottom: 0; -} -.position-editor .handle { - background-color: #d2e9ff; - border: 1px solid #1e90ff; -} -.position-editor .add-span-handle { - background-color: #e9f4ff; - border: 1px dotted #1e90ff; -} -.position-editor .top-handle, -.position-editor .bottom-handle, -.position-editor .add-span-handle { - border-radius: 3px; -} -.note-editor .note-connector { - stroke: #1e90ff; - stroke-width: 3; -} -.col-note-label { - padding-left: 0.3em; - border-left: 1px solid var(--note-color); - margin: 1px 0; - font-style: italic; - z-index: 15; -} -g.height-range line { - stroke: var(--note-color); - stroke-width: 1.2px; -} - .column-image { position: absolute; top: 0; @@ -79,32 +38,6 @@ g.height-range line { } :global { - g.note, - g.note-editor { - --note-color: var( - --column-note-color, - var(--column-stroke-color, var(--text-color, #000)) - ); - } - g.note circle, - g.note-editor circle { - fill: var(--note-color); - } - g.note .link, - g.note-editor .link { - stroke: var(--note-color); - stroke-width: 1; - fill: none; - } - .new-note circle { - fill: #1e90ff; - } - .new-note line { - stroke: #1e90ff; - stroke-width: 3; - marker-start: url("#new_arrow_start"); - marker-end: url("#new_arrow_end"); - } #new_arrow_start, #new_arrow_end { fill: #1e90ff; diff --git a/packages/column-components/src/notes/connector.ts b/packages/column-components/src/notes/connector.ts index b3b915278..2ee6bed0a 100644 --- a/packages/column-components/src/notes/connector.ts +++ b/packages/column-components/src/notes/connector.ts @@ -40,7 +40,6 @@ const NotePositioner = forwardRef(function ( { ref, onClick, - style: { margin: outerPad }, }, children, ), diff --git a/packages/column-components/src/notes/editor.ts b/packages/column-components/src/notes/editor.ts index 13b27d4e9..a893b2569 100644 --- a/packages/column-components/src/notes/editor.ts +++ b/packages/column-components/src/notes/editor.ts @@ -35,9 +35,9 @@ const NoteTextEditor = function (props: NoteEditorProps) { interface NoteEditorProviderProps { inEditMode: boolean; noteEditor: ComponentType; - onUpdateNote: (n: NoteData) => void; - onDeleteNote: (n: NoteData) => void; - onCreateNote: Function; + onUpdateNote?: (n: NoteData) => void; + onDeleteNote?: (n: NoteData) => void; + onCreateNote?: Function; children?: React.ReactNode; } @@ -50,7 +50,7 @@ function NoteEditorProvider(props: NoteEditorProviderProps) { const deleteNote = function () { const val = editingNote; setEditingNote(null); - return props.onDeleteNote(val); + return props.onDeleteNote?.(val); }; const onCreateNote = function (pos) { @@ -76,7 +76,7 @@ function NoteEditorProvider(props: NoteEditorProviderProps) { if (notes.includes(n)) { return; } - return props.onUpdateNote(n); + return props.onUpdateNote?.(n); }; //# Model editor provider gives us a nice store @@ -257,22 +257,19 @@ function PositionEditorInner(props) { ); } -const NoteEditorUnderlay = function ({ padding }) { - const { width } = useContext(NoteLayoutContext); - const { setEditingNote } = useContext(NoteEditorContext) as any; +function NoteEditorUnderlay() { return h(NoteRect, { - fill: "rgba(255,255,255,0.8)", style: { pointerEvents: "none" }, className: "underlay", }); -}; +} -const NoteEditor = function (props) { +function NoteEditor(props) { const { allowPositionEditing } = props; - const { noteEditor } = useContext(NoteEditorContext) as any; + const { noteEditor, setEditingNote } = useContext(NoteEditorContext) as any; const { notes, nodes, elementHeights, createNodeForNote } = useContext(NoteLayoutContext); - const { editedModel } = useModelEditor(); + const { editedModel, model } = useModelEditor(); if (editedModel == null) { return null; } @@ -297,6 +294,8 @@ const NoteEditor = function (props) { node = newNode; } + const edited = editedModel === model; + return h(ErrorBoundary, [ h("g.note-editor.note", [ h(NoteEditorUnderlay), @@ -305,15 +304,30 @@ const NoteEditor = function (props) { note: editedModel, node, }), - h(NotePositioner, { offsetY: node.currentPos, noteHeight }, [ - h(noteEditor, { - note: editedModel, - key: index, - }), - ]), + h( + NotePositioner, + { + offsetY: node.currentPos, + noteHeight, + onClick(evt) { + if (edited) { + setEditingNote(null); + evt.stopPropagation(); + } + }, + }, + [ + h(noteEditor, { + note: editedModel, + key: index, + focused: true, + edited, + }), + ], + ), ]), ]); -}; +} export type { NoteData }; export { NoteEditorProvider, NoteEditorContext, NoteTextEditor, NoteEditor }; diff --git a/packages/column-components/src/notes/height-range.ts b/packages/column-components/src/notes/height-range.ts index 9f7d81fda..d6e57f339 100644 --- a/packages/column-components/src/notes/height-range.ts +++ b/packages/column-components/src/notes/height-range.ts @@ -8,6 +8,7 @@ interface HeightRangeAnnotationProps { offsetX?: number; color?: string; lineInset?: number; + circleRadius?: number; } function HeightRangeAnnotation(props: HeightRangeAnnotationProps) { @@ -18,6 +19,7 @@ function HeightRangeAnnotation(props: HeightRangeAnnotationProps) { offsetX = 0, color, lineInset = 1, + circleRadius = 2, ...rest } = props; @@ -28,7 +30,11 @@ function HeightRangeAnnotation(props: HeightRangeAnnotationProps) { } const topHeight = bottomHeight - pxHeight; - const isLine = pxHeight > 2 * lineInset; + /* Use a value slightly greater than the circle diameter as the cutoff + to switch between line and circle, to account for a circle's greater + visual weight than the equivalent line height + */ + const isLine = pxHeight > 3 * Math.max(lineInset, circleRadius); const transform = `translate(${offsetX},${topHeight})`; @@ -40,7 +46,7 @@ function HeightRangeAnnotation(props: HeightRangeAnnotationProps) { y2: pxHeight - lineInset, }), h.if(!isLine)("circle", { - r: 2, + r: circleRadius, transform: `translate(0,${pxHeight / 2})`, }), ]); diff --git a/packages/column-components/src/notes/index.ts b/packages/column-components/src/notes/index.ts index 8531d7143..d7116942a 100644 --- a/packages/column-components/src/notes/index.ts +++ b/packages/column-components/src/notes/index.ts @@ -1,4 +1,4 @@ -import { ComponentType, useContext } from "react"; +import { ComponentType, useCallback, useContext, useState } from "react"; import h from "../hyper"; import { NotesList } from "./note"; import NoteDefs from "./defs"; @@ -37,29 +37,38 @@ function NoteComponent(props: NoteComponentProps) { const CancelEditUnderlay = function () { const { setEditingNote } = useContext(NoteEditorContext) as any; - const { confirmChanges } = useModelEditor(); return h(NoteUnderlay, { - onClick() { - console.log("Clicked to cancel note editing"); - return setEditingNote(null); + onClick(evt) { + setEditingNote(null); + evt.stopPropagation(); }, }); }; -interface EditableNotesColumnProps { +interface NotesColumnBaseProps extends NodeConnectorOptions { width?: number; paddingLeft?: number; transform?: string; notes?: NoteData[]; + noteComponent?: ComponentType; + onClickNote?: (note: NoteData) => void; + children?: ReactNode; + forceOptions?: object; +} + +interface EditableNotesColumnProps extends NotesColumnBaseProps { inEditMode?: boolean; onUpdateNote?: (n: NoteData) => void; onDeleteNote?: (n: NoteData) => void; onCreateNote?: Function; - noteComponent?: ComponentType; noteEditor?: ComponentType; allowPositionEditing?: boolean; - forceOptions?: object; - onClickNote?: (note: NoteData) => void; +} + +interface FocusedNotesColumnProps extends NotesColumnBaseProps { + focusedNote?: NoteData | null; + onFocusNote?: (note: NoteData | null) => void; + focusedNoteComponent?: ComponentType; } function EditableNotesColumn(props: EditableNotesColumnProps) { @@ -101,12 +110,10 @@ function EditableNotesColumn(props: EditableNotesColumnProps) { onDeleteNote, }, [ - h("g.section-log", { transform }, [ + h("g.section-log", { transform, className: "focusable editable" }, [ h(NoteDefs), h(CancelEditUnderlay), h(NotesList, { - editHandler: inEditMode ? onUpdateNote : null, - inEditMode, onClickNote, }), h(NewNotePositioner), @@ -118,14 +125,52 @@ function EditableNotesColumn(props: EditableNotesColumnProps) { ); } -interface NotesColumnBaseProps extends NodeConnectorOptions { - width?: number; - paddingLeft?: number; - transform?: string; - notes?: NoteData[]; - noteComponent?: ComponentType; - onClickNote?: (note: NoteData) => void; - children?: ReactNode; +function FocusableNoteColumn(props: FocusedNotesColumnProps) { + /** A notes column with selection capabilities. */ + const { + width, + paddingLeft = 60, + transform, + notes, + forceOptions, + noteComponent = NoteComponent, + focusedNoteComponent = NoteComponent, + deltaConnectorAttachment, + onClickNote, + } = props; + + const innerWidth = width - paddingLeft; + + return h( + NoteLayoutProvider, + { + notes, + width: innerWidth, + paddingLeft, + noteComponent, + forceOptions, + }, + [ + h( + NoteEditorProvider, + { + inEditMode: true, + noteEditor: focusedNoteComponent, + }, + [ + h("g.section-log", { transform, className: "focusable" }, [ + h(NoteDefs), + h(CancelEditUnderlay), + h(NotesList, { + onClickNote, + deltaConnectorAttachment, + }), + h(NoteEditor, { allowPositionEditing: false }), + ]), + ], + ), + ], + ); } function StaticNotesColumn(props: NotesColumnBaseProps) { @@ -138,6 +183,7 @@ function StaticNotesColumn(props: NotesColumnBaseProps) { noteComponent = NoteComponent, deltaConnectorAttachment, onClickNote, + forceOptions, children, } = props; @@ -150,12 +196,12 @@ function StaticNotesColumn(props: NotesColumnBaseProps) { width: innerWidth, paddingLeft, noteComponent, + forceOptions, }, [ h("g.section-log", { transform }, [ h(NoteDefs), h(NotesList, { - inEditMode: false, deltaConnectorAttachment, onClickNote, }), @@ -165,17 +211,23 @@ function StaticNotesColumn(props: NotesColumnBaseProps) { ); } -function NotesColumn(props) { - const { editable = true, ...rest } = props; +function NotesColumn(props: NotesColumnProps) { + const { editable = false, ...rest } = props; const ctx = useContext(ColumnContext); // not sure why we have this here. if (ctx?.scaleClamped == null) return null; - const c: ComponentType = editable ? EditableNotesColumn : StaticNotesColumn; + let c: ComponentType = StaticNotesColumn; + if (editable) { + c = EditableNotesColumn; + } else if (rest.focusedNoteComponent != null) { + c = FocusableNoteColumn; + } return h(c, rest); } -interface NotesColumnProps { +interface NotesColumnProps + extends FocusedNotesColumnProps, EditableNotesColumnProps { editable?: boolean; } diff --git a/packages/column-components/src/notes/layout.ts b/packages/column-components/src/notes/layout.ts index 00337f844..88460defa 100644 --- a/packages/column-components/src/notes/layout.ts +++ b/packages/column-components/src/notes/layout.ts @@ -1,6 +1,6 @@ import { createContext, ReactNode, useContext } from "react"; import { StatefulComponent } from "@macrostrat/ui-components"; -import h from "@macrostrat/hyper"; +import h from "../hyper"; import { hasSpan } from "./utils"; import { FlexibleNode, Force, Node, Renderer } from "./label-primitives"; @@ -306,7 +306,7 @@ class NoteLayoutProvider extends StatefulComponent< } } -const NoteRect = function (props) { +function NoteRect(props) { let { padding, width, ...rest } = props; if (padding == null) { padding = 5; @@ -325,15 +325,11 @@ const NoteRect = function (props) { transform: `translate(${-padding},${-padding})`, ...rest, }); -}; +} -const NoteUnderlay = function ({ fill, ...rest }) { - if (fill == null) { - fill = "transparent"; - } +const NoteUnderlay = function ({ ...rest }) { return h(NoteRect, { className: "underlay", - fill, ...rest, }); }; diff --git a/packages/column-components/src/notes/note-editor.old.styl b/packages/column-components/src/notes/note-editor.old.styl deleted file mode 100644 index 262e84981..000000000 --- a/packages/column-components/src/notes/note-editor.old.styl +++ /dev/null @@ -1,26 +0,0 @@ -.column-notes - .col-note-label - cursor: pointer - - .note-editor.bp5-editable-text - user-select none - &:before - top: 0 - left: 0 - right: 0 - bottom: 0 - -.position-editor - .handle - background-color: lighten(dodgerblue, 80%) - border: 1px solid dodgerblue - .add-span-handle - background-color: lighten(dodgerblue, 90%) - border 1px dotted dodgerblue - .top-handle, .bottom-handle, .add-span-handle - border-radius 3px - -.note-editor - .note-connector - stroke: dodgerblue - stroke-width 3 diff --git a/packages/column-components/src/notes/note-editor.scss b/packages/column-components/src/notes/note-editor.scss deleted file mode 100644 index 4686e52f2..000000000 --- a/packages/column-components/src/notes/note-editor.scss +++ /dev/null @@ -1,29 +0,0 @@ -.column-notes .col-note-label { - cursor: pointer; -} -.column-notes .note-editor.bp5-editable-text { - user-select: none; -} -.column-notes .note-editor.bp5-editable-text:before { - top: 0; - left: 0; - right: 0; - bottom: 0; -} -.position-editor .handle { - background-color: #d2e9ff; - border: 1px solid #1e90ff; -} -.position-editor .add-span-handle { - background-color: #e9f4ff; - border: 1px dotted #1e90ff; -} -.position-editor .top-handle, -.position-editor .bottom-handle, -.position-editor .add-span-handle { - border-radius: 3px; -} -.note-editor .note-connector { - stroke: #1e90ff; - stroke-width: 3; -} diff --git a/packages/column-components/src/notes/note.old.styl b/packages/column-components/src/notes/note.old.styl deleted file mode 100644 index 7e1dd5a1c..000000000 --- a/packages/column-components/src/notes/note.old.styl +++ /dev/null @@ -1,41 +0,0 @@ -@require "./note-editor.styl" - -.col-note-label - padding-left: 0.3em - border-left: 1px solid var(--note-color) - //margin-top: -8px - //transform: translateY(-50%) - font-style: italic - margin: 0 - z-index: 15 - -g.height-range - line - stroke: var(--note-color) - stroke-width: 1.2px - //marker-start: url('#arrow_start') - //marker-end: url('#arrow_end') - -g.note, g.note-editor - --note-color: var(--column-note-color, var(--stroke-color, black)) - circle - fill: var(--note-color) - - //div.note-label - // transform: translateY(30px) - .link - stroke: var(--note-color) - stroke-width: 1 - fill: none - -.new-note - circle - fill: dodgerblue - line - stroke: dodgerblue - stroke-width: 3 - marker-start: url('#new_arrow_start') - marker-end: url('#new_arrow_end') - -#new_arrow_start, #new_arrow_end - fill dodgerblue diff --git a/packages/column-components/src/notes/note.scss b/packages/column-components/src/notes/note.scss deleted file mode 100644 index ad53081b4..000000000 --- a/packages/column-components/src/notes/note.scss +++ /dev/null @@ -1,66 +0,0 @@ -.column-notes .col-note-label { - cursor: pointer; -} -.column-notes .note-editor.bp5-editable-text { - user-select: none; -} -.column-notes .note-editor.bp5-editable-text:before { - top: 0; - left: 0; - right: 0; - bottom: 0; -} -.position-editor .handle { - background-color: #d2e9ff; - border: 1px solid #1e90ff; -} -.position-editor .add-span-handle { - background-color: #e9f4ff; - border: 1px dotted #1e90ff; -} -.position-editor .top-handle, -.position-editor .bottom-handle, -.position-editor .add-span-handle { - border-radius: 3px; -} -.note-editor .note-connector { - stroke: #1e90ff; - stroke-width: 3; -} -.col-note-label { - padding-left: 0.3em; - border-left: 1px solid var(--note-color); - font-style: italic; - z-index: 15; -} -g.height-range line { - stroke: var(--note-color); - stroke-width: 1.2px; -} -g.note, -g.note-editor { - --note-color: var(--column-note-color, var(--stroke-color, #000)); -} -g.note circle, -g.note-editor circle { - fill: var(--note-color); -} -g.note .link, -g.note-editor .link { - stroke: var(--note-color); - stroke-width: 1; - fill: none; -} -.new-note circle { - fill: #1e90ff; -} -.new-note line { - stroke: #1e90ff; - stroke-width: 3; - marker-start: url("#new_arrow_start"); - marker-end: url("#new_arrow_end"); -} -#new_arrow_start, -#new_arrow_end { - fill: #1e90ff; -} diff --git a/packages/column-components/src/notes/note.ts b/packages/column-components/src/notes/note.ts index 61cf9e100..e1373d2f3 100644 --- a/packages/column-components/src/notes/note.ts +++ b/packages/column-components/src/notes/note.ts @@ -11,17 +11,12 @@ import { import { useColumn } from "../context"; type NoteListProps = NodeConnectorOptions & { - inEditMode?: boolean; - editable?: boolean; onClickNote?: (note: NoteData) => void; - editHandler?: Function; }; export function NotesList(props: NoteListProps) { - let { inEditMode: editable, onClickNote, ...rest } = props; - if (editable == null) { - editable = false; - } + let { onClickNote, ...rest } = props; + const { notes, nodes: nodeIndex, @@ -66,7 +61,7 @@ export function NotesList(props: NoteListProps) { }, [notes, nodeIndex, scale]); return h( - "g", + "g.notes-list", notesInfo.map(({ note, pixelOffset, pixelHeight, spacing }) => { // If the note has a bad pixelOffset, skip it @@ -79,7 +74,6 @@ export function NotesList(props: NoteListProps) { note, pixelOffset, pixelHeight, - editable, updateHeight, onClick: onClickNote, noteBodyComponent: noteComponent, @@ -95,12 +89,8 @@ type NodeSpacing = { below: number; }; -type NodeInfo = any; - interface NoteProps { - editable: boolean; note: NoteData; - editHandler?: Function; style?: object; deltaConnectorAttachment?: number; pixelOffset?: number; @@ -138,9 +128,12 @@ function Note(props: NoteProps) { const { setEditingNote, editingNote } = useContext(NoteEditorContext) as any; const onClick_ = onClick ?? setEditingNote; - const _onClickHandler = (evt) => { - onClick_(note); - }; + const _onClickHandler = useMemo(() => { + if (!onClick_) return undefined; + return (evt) => { + onClick_(note); + }; + }, [onClick_]); if (editingNote === note) { return null; diff --git a/packages/column-components/src/notes/notes.module.sass b/packages/column-components/src/notes/notes.module.sass new file mode 100644 index 000000000..3f30f9682 --- /dev/null +++ b/packages/column-components/src/notes/notes.module.sass @@ -0,0 +1,98 @@ +.column-notes .col-note-label + cursor: pointer + + +.column-notes .note-editor.bp5-editable-text + user-select: none + +.column-notes .note-editor.bp5-editable-text:before + top: 0 + left: 0 + right: 0 + bottom: 0 + +.position-editor .handle + background-color: #d2e9ff + border: 1px solid #1e90ff + +.position-editor .add-span-handle + background-color: #e9f4ff + border: 1px dotted #1e90ff + +.position-editor .top-handle, +.position-editor .bottom-handle, +.position-editor .add-span-handle + border-radius: 3px + +.note-editor .note-connector + stroke: #1e90ff + stroke-width: 3 + +.col-note-label + padding-left: 0.3em + border-left: 1px solid var(--note-color) + margin: 1px 0 + font-style: italic + z-index: 15 + +g.height-range line + stroke: var(--note-color) + stroke-width: 1.2px + + +.underlay + fill: var(--column-background-color) + fill-opacity: 0.8 + pointer-events: all + +.notes-list:has(+ .note-editor) + // Target the note list that is undergoing editing + cursor: pointer + .note + pointer-events: none + .note-inner + cursor: pointer + pointer-events: all + &>* + pointer-events: none + +.note-inner + margin: 5px + +.focusable + .note-inner + cursor: pointer + +.note-editor + // This seems to be needed due to the overflow-y: scroll on .note-inner + &>foreignObject + transform: translate(0, -1px) + .note-inner + overflow-y: scroll + max-height: var(--not-editor-max-height, 500px) + + +g.note, g.note-editor + --note-color: var(--column-note-color, var(--column-stroke-color, var(--text-color, #000))) + + circle + fill: var(--note-color) + + .link + stroke: var(--note-color) + stroke-width: 1 + fill: none + +.note-editor .underlay + cursor: pointer + +.new-note + circle + fill: #1e90ff + + line + stroke: #1e90ff + stroke-width: 3 + marker-start: url("#new_arrow_start") + marker-end: url("#new_arrow_end") + diff --git a/packages/column-views/CHANGELOG.md b/packages/column-views/CHANGELOG.md index 41d4811cb..307630f49 100644 --- a/packages/column-views/CHANGELOG.md +++ b/packages/column-views/CHANGELOG.md @@ -4,6 +4,17 @@ 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). +## [2.3.0] - 2025-12-10 + +- Streamline column facet components +- Create a mode for facets that allows focusing a single column-associated + measurement +- Improve scale calculations in some edge cases +- Condense notes that are close together +- Add explicitly defined height where available from PBDB (eODP columns, mostly) +- Fixed axis label spacing +- Small bug fixes for unit selection + ## [2.2.2] - 2025-12-04 - Fix a bug with unit deselection diff --git a/packages/column-views/package.json b/packages/column-views/package.json index 8c4845455..6659584ca 100644 --- a/packages/column-views/package.json +++ b/packages/column-views/package.json @@ -1,6 +1,6 @@ { "name": "@macrostrat/column-views", - "version": "2.2.2", + "version": "2.3.0", "description": "Data views for Macrostrat stratigraphic columns", "type": "module", "source": "src/index.ts", diff --git a/packages/column-views/src/age-axis.ts b/packages/column-views/src/age-axis.ts index 52213a3cd..c93b1c059 100644 --- a/packages/column-views/src/age-axis.ts +++ b/packages/column-views/src/age-axis.ts @@ -11,7 +11,7 @@ import styles from "./age-axis.module.sass"; import { useCompositeScale, useMacrostratColumnData } from "./data-provider"; import { Parenthetical } from "@macrostrat/data-components"; import { AgeLabel } from "./unit-details"; -import { PackageScaleLayoutData } from "./prepare-units/types"; +import { PackageScaleLayoutData } from "./prepare-units"; const h = hyper.styled(styles); diff --git a/packages/column-views/src/column.ts b/packages/column-views/src/column.ts index 8e0f149c0..29c8dd5ba 100644 --- a/packages/column-views/src/column.ts +++ b/packages/column-views/src/column.ts @@ -52,7 +52,10 @@ import { ScaleContinuousNumeric } from "d3-scale"; const h = hyperStyled(styles); -interface BaseColumnProps extends SectionSharedProps { +interface BaseColumnProps extends Omit< + SectionSharedProps, + "unconformityLabels" +> { className?: string; showLabelColumn?: boolean; keyboardNavigation?: boolean; 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 938be8dda..aa2239f46 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 @@ -8,7 +8,7 @@ import { fetchUnits, MergeSectionsMode, useCorrelationMapStore, -} from "@macrostrat/column-views"; +} from "../.."; import { hyperStyled } from "@macrostrat/hyper"; import styles from "./stories.module.sass"; diff --git a/packages/column-views/src/facets/base-sample-column.ts b/packages/column-views/src/facets/base-sample-column.ts deleted file mode 100644 index b51edb915..000000000 --- a/packages/column-views/src/facets/base-sample-column.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { useMacrostratColumnData } from "../data-provider"; -import { useCallback, useMemo } from "react"; -import hyper from "@macrostrat/hyper"; -import styles from "./base-sample-column.module.sass"; -import { getUnitHeightRange } from "../prepare-units"; -import { ColumnNotes } from "../notes"; -const h = hyper.styled(styles); - -export interface BaseMeasurementsColumnProps { - data: T[]; - noteComponent?: any; - width?: number; - paddingLeft?: number; - className?: string; - // TODO: these props are confusing - getUnitID?: (d: T) => number | string; - matchingUnit?: (dz: T) => (d: any) => boolean; -} - -export function BaseMeasurementsColumn({ - data, - noteComponent, - width = 500, - paddingLeft = 40, - className, - getUnitID = (d) => d.unit_id, - matchingUnit, -}: BaseMeasurementsColumnProps) { - const { axisType, units } = useMacrostratColumnData(); - - const _matchingUnit = - matchingUnit ?? - useCallback( - (dz) => { - return (d) => { - return getUnitID(d) === dz.unit_id; - }; - }, - [getUnitID], - ); - - const notes: any[] = useMemo(() => { - if (data == null || units == null) return []; - let unitRefData = Array.from(data.values()) - .map((d) => { - return { - data: d, - unit: units.find(_matchingUnit(d)), - }; - }) - .filter((d) => d.unit != null); - - unitRefData.sort((a, b) => { - const v1 = units.indexOf(a.unit); - const v2 = units.indexOf(b.unit); - return v1 - v2; - }); - - return unitRefData.map((d) => { - const { unit, data } = d; - const heightRange = getUnitHeightRange(unit, axisType); - - return { - top_height: heightRange[1], - height: heightRange[0], - data, - unit, - id: unit.unit_id, - }; - }); - }, [data, units, matchingUnit]); - - if (data == null || units == null) return null; - - return h( - "div", - { className }, - h(ColumnNotes, { - width, - paddingLeft, - notes, - noteComponent, - }), - ); -} - -interface TruncatedListProps { - data: any[]; - className?: string; - maxItems?: number; - itemRenderer?: (props: { data: any }) => any; -} - -export function TruncatedList({ - data, - className, - maxItems = 5, - itemRenderer = (p) => h("span", p.data), -}: TruncatedListProps) { - let tooMany = null; - let d1 = data; - if (data.length > maxItems) { - const n = data.length - maxItems; - d1 = data.slice(0, maxItems); - tooMany = h("li.too-many", `and ${n} more`); - } - - return h("ul.truncated-list", { className }, [ - d1.map((d, i) => { - return h("li.element", { key: i }, h(itemRenderer, { data: d })); - }), - tooMany, - ]); -} diff --git a/packages/column-views/src/facets/detrital-zircon/index.ts b/packages/column-views/src/facets/detrital-zircon/index.ts index c63b6bf6e..a5787b712 100644 --- a/packages/column-views/src/facets/detrital-zircon/index.ts +++ b/packages/column-views/src/facets/detrital-zircon/index.ts @@ -9,15 +9,20 @@ import { useDetritalMeasurements, MeasurementInfo } from "./provider"; import { useMemo } from "react"; import styles from "./index.module.sass"; import classNames from "classnames"; -import { BaseMeasurementsColumn } from "../base-sample-column"; +import { + BaseMeasurementsColumn, + mergeHeightRanges, + ColumnMeasurementData, +} from "../measurements"; +import { group } from "d3-array"; +import { ColumnAxisType } from "@macrostrat/column-components"; +import { useMacrostratColumnData } from "../../data-provider"; +import { getUnitHeightRange } from "../../prepare-units"; const h = hyper.styled(styles); interface DetritalItemProps { - note: { - data: MeasurementInfo[]; - unit?: IUnit; - }; + note: DZMeasurementInfo; spacing?: { below?: number; above?: number; @@ -27,7 +32,61 @@ interface DetritalItemProps { color?: string; } -const matchingUnit = (dz) => (d) => d.unit_id == dz[0].unit_id; +interface DZMeasurementInfo extends ColumnMeasurementData { + units: IUnit[]; +} + +function prepareDetritalData( + data: MeasurementInfo[], + units: IUnit[], + axisType: ColumnAxisType, +) { + /** Right now measurement data could be duplicated if there are multiple units linked to the same + * measuremeta_id. THis happens because matches to units might be at a lower rank (e.g, if the column + * contains Formations but the measurements are linked to a Group). To handle this, we group by measuremeta_id + * and then create unique keys based on the set of units linked to each measurement. + */ + + // Group data by measuremeta_id + const measurementsGrouped = group(data, (d) => d.measuremeta_id); + + const resMap = new Map(); + + for (const measurements of measurementsGrouped.values()) { + // Get a list of unique unit_ids for this measurement + const unitIDs = new Set(measurements.map((m) => m.unit_id)); + const ids = Array.from(unitIDs); + ids.sort(); + // Key is unique to the set of units + const key = ids.join("-"); + + if (!resMap.has(key)) { + const unitData = ids + .map((id) => { + return units.find((u) => u.unit_id === id); + }) + .filter(Boolean); + + const positions = unitData.map((unit) => { + const [height, top_height] = getUnitHeightRange(unit, axisType); + return { height, top_height }; + }); + + // merge positions (note: we could also have multiple separate notes per measurement) + const pos = mergeHeightRanges(positions, axisType); + + resMap.set(key, { + id: key, + data: [], + units: unitData as IUnit[], + ...pos, + }); + } + resMap.get(key)!.data.push(measurements[0]); + } + + return Array.from(resMap.values()); +} function DetritalColumn({ columnID, color = "magenta" }) { const data = useDetritalMeasurements({ col_id: columnID }); @@ -48,11 +107,17 @@ function DetritalColumn({ columnID, color = "magenta" }) { }; }, [width, color]); + const { axisType, units } = useMacrostratColumnData(); + + const data1 = useMemo(() => { + if (data == null || units == null) return null; + return prepareDetritalData(data, units, axisType); + }, [data, units, axisType]); + return h(BaseMeasurementsColumn, { - data, + data: data1, noteComponent, - getUnitID: (d) => d[0].unit_id, - matchingUnit, + deltaConnectorAttachment: 20, }); } @@ -68,7 +133,7 @@ function DepositionalAge({ unit }) { function DetritalGroup(props: DetritalItemProps) { const { note, width, height, color, spacing } = props; - const { data, unit } = note; + const { data, units } = note; const _color = color; @@ -83,7 +148,9 @@ function DetritalGroup(props: DetritalItemProps) { DetritalSpectrumPlot, { width, innerHeight: height, showAxisLabels: true, paddingBottom: 40 }, [ - h.if(unit != null)(DepositionalAge, { unit }), + units.map((unit) => { + return h(DepositionalAge, { unit }); + }), data.map((d) => { return h(DetritalSeries, { bandwidth: 20, diff --git a/packages/column-views/src/facets/detrital-zircon/provider.ts b/packages/column-views/src/facets/detrital-zircon/provider.ts index bc6be11e9..6c21603df 100644 --- a/packages/column-views/src/facets/detrital-zircon/provider.ts +++ b/packages/column-views/src/facets/detrital-zircon/provider.ts @@ -47,5 +47,5 @@ export function useDetritalMeasurements(columnArgs) { columnArgs, ); if (res == null) return null; - return group(res, (d) => d.unit_id); + return res; } diff --git a/packages/column-views/src/facets/fossils/index.ts b/packages/column-views/src/facets/fossils/index.ts index 127184ecd..ec7b26daa 100644 --- a/packages/column-views/src/facets/fossils/index.ts +++ b/packages/column-views/src/facets/fossils/index.ts @@ -2,26 +2,58 @@ import hyper from "@macrostrat/hyper"; import { FossilDataType, PBDBCollection, + PBDBEntity, PBDBOccurrence, useFossilData, } from "./provider"; -import type { IUnit } from "../../units"; -import { BaseMeasurementsColumn, TruncatedList } from "../base-sample-column"; -import { Box, useElementSize } from "@macrostrat/ui-components"; -import { InternMap } from "d3-array"; -import { ColumnAxisType, ColumnSVG } from "@macrostrat/column-components"; +import type { CompositeColumnScale, IUnit } from "../../units"; +import { + BaseMeasurementsColumn, + ColumnMeasurementData, + MeasurementHeightData, + standardizeMeasurementHeight, + groupNotesByPixelDistance, + TruncatedList, +} from "../measurements"; +import { ColumnAxisType } from "@macrostrat/column-components"; import { - useMacrostratColumnData, useCompositeScale, + useMacrostratColumnData, } from "../../data-provider"; import { UnitLong } from "@macrostrat/api-types"; -import styles from "./index.module.sass"; -import { useRef } from "react"; +import styles from "./taxon-ranges.module.sass"; +import { getPositionWithinUnit, getUnitHeightRange } from "../../prepare-units"; +import { scaleLinear } from "d3-scale"; + +export * from "./taxon-ranges"; const h = hyper.styled(styles); export { FossilDataType }; +export function PBDBFossilsColumn({ + columnID, + type = FossilDataType.Collections, +}: { + columnID: number; + type: FossilDataType; +}) { + const data = useFossilData(columnID, type); + const { axisType, units } = useMacrostratColumnData(); + const scale = useCompositeScale(); + + if (data == null || units == null || scale == null) return null; + + const data1 = preparePBDBData(data, units, scale, axisType); + + return h(BaseMeasurementsColumn, { + data: data1, + noteComponent: FossilInfo, + focusedNoteComponent: FossilInfo, + className: "fossil-collections", + }); +} + interface FossilItemProps { note: { data: PBDBCollection[]; @@ -34,19 +66,30 @@ interface FossilItemProps { width?: number; height?: number; color?: string; + focused?: boolean; } function FossilInfo(props: FossilItemProps) { - const { note, spacing } = props; - const { data, unit } = note; + const { note, maxItems, focused = false } = props; + const { data } = note; + // Sort collections by name + data.sort((a, b) => { + const nameA = a.best_name ?? a.cltn_name ?? ""; + const nameB = b.best_name ?? b.cltn_name ?? ""; + return nameA.localeCompare(nameB); + }); return h(TruncatedList, { data, className: "fossil-collections", itemRenderer: PBDBCollectionLink, + maxItems: focused ? Infinity : (maxItems ?? 5), }); } +const FocusedFossilInfo = (props: FossilItemProps) => + h(FossilInfo, { ...props, maxItems: Infinity }); + function PBDBCollectionLink({ data, }: { @@ -56,231 +99,118 @@ function PBDBCollectionLink({ return h( "a.link-id", { - href: `https://paleobiodb.org/classic/basicCollectionSearch?collection_no=${data.cltn_id}`, + href: `https://paleobiodb.org/app/collections#display=col:${data.cltn_id}`, + target: "_blank", + onClick(e) { + e.stopPropagation(); + }, }, data.best_name ?? data.cltn_name, ); } -const matchingUnit = (dz) => (d) => d.unit_id == dz[0].unit_id; - -export function PBDBFossilsColumn({ - columnID, - type = FossilDataType.Collections, -}: { - columnID: number; - type: FossilDataType; -}) { - const data = useFossilData(columnID, type); - - return h(BaseMeasurementsColumn, { - data, - noteComponent: FossilInfo, - className: "fossil-collections", - matchingUnit, - }); -} - -export function PBDBOccurrencesMatrix({ columnID }) { - /* A column for a matrix of taxon occurrences displayed as a table beside the main column. This will - eventually be extended with first/last occurrence markers and range bars. +interface PreparePBDBDataOptions { + /** If set, group close notes within this distance (in pixels in display space) + * into a single note. If a number is provided, that number is used as the distance, + * otherwise a default of 5 pixels is used. */ - const data = useFossilData(columnID, FossilDataType.Occurrences) as InternMap< - number, - PBDBOccurrence[] - >; - - // convert the data to a map - const occurrenceMap = new Map(data); - - const col = useMacrostratColumnData(); - const matrix = createOccurrenceMatrix(col.units, occurrenceMap, col.axisType); - - const scale = useCompositeScale(); - - const { taxonRanges } = matrix; - - const padding = 16; - const spacing = 16; - - const taxonEntries = Array.from(taxonRanges.entries()); - //const taxon = taxonEntries.slice(0, 50); // limit to top 50 taxa - - const width = padding * 2 + spacing * taxonEntries.length; - - return h(Box, { className: "taxon-ranges", width, height: col.totalHeight }, [ - h(TaxonOccurrenceLabels, { - taxonEntries, - padding, - spacing, - scale, - }), - h( - ColumnSVG, - { - width: padding * 2 + spacing * taxonEntries.length, - }, - h( - "g.taxa-occurrences-matrix", - taxonEntries.map(([taxonName, ranges], rowIndex) => { - const xPosition = padding + rowIndex * spacing; - return h("g", { transform: `translate(${xPosition})` }, [ - ranges.map(([top, bottom]) => { - return h("line", { - y1: scale(top), - y2: scale(bottom), - }); - }), - ]); - }), - ), - ), - ]); + groupCloseNotes?: boolean | number; } -function TaxonOccurrenceLabels({ taxonEntries, padding, spacing, scale }) { - return h("div.taxon-labels", [ - taxonEntries.map(([taxonName, ranges], rowIndex) => { - const top = ranges[0]?.[0] ?? 0; - let topPx = scale(top) - 20; - if (topPx < 200) topPx = 0; +function preparePBDBData( + data: T[], + units: UnitLong[], + scale: CompositeColumnScale, + axisType: ColumnAxisType, + options?: { groupCloseNotes?: boolean | number }, +) { + /** Prepare PBDB fossil data for display in a measurements column */ + const { groupCloseNotes = true } = options ?? {}; + const groupDistance = + typeof groupCloseNotes === "number" ? groupCloseNotes : 10; + + // Map of data to its defined height ranges + const dataMap = new Map>(); + + // Todo: if we wanted, we could add a step where we group notes that are too close together here... + + for (const d of data) { + const range = getHeightRangeForPBDBEntity(d, units, axisType); + + if (range == null) continue; + const { height, top_height } = range; + // compose the key based on height info + let key = `${height}`; + if (top_height != null) { + key += `-${top_height}`; + } - return h(TaxonLabel, { - top: topPx, - left: padding + rowIndex * spacing, - taxonName, + // Group by height key + if (!dataMap.has(key)) { + dataMap.set(key, { + height, + top_height: top_height ?? height, + data: [], + id: key, }); - }), - ]); -} + } + dataMap.get(key)!.data.push(d); + } -function TaxonLabel({ top, left, taxonName }) { - const ref = useRef(); - const textSize = useElementSize(ref); - const labelWidth = textSize?.height ?? 200; - return h( - "div.taxon-label", - { - style: { - top: `${top}px`, - marginLeft: `${left}px`, - "--label-width": `${labelWidth}px`, - }, - }, - h("div.taxon-label-inner", h("div.taxon-label-text", { ref }, taxonName)), + return groupNotesByPixelDistance( + Array.from(dataMap.values()), + scale, + axisType, + groupDistance, ); } -type TaxonUnitMap = Map>; - -interface OccurrenceMatrixData { - occurrenceMap: Map; // Map of unit IDs to occurrences (original data) - taxonUnitMap: TaxonUnitMap; // Map of taxon names to sets of unit IDs - taxonOccurrenceMap: Map; // Map of taxon names to occurrences - taxonRanges: Map; // Map of taxon names to [top, bottom] pixel ranges -} - -function TaxonOccurrenceEntry({ - xPosition, - ranges, - scale, - name, -}: { - xPosition: number; - units: Set; -}) { - return h("g", { transform: `translate(${xPosition})` }, [ - ranges.map(([top, bottom]) => { - return h("line", { - y1: scale(top), - y2: scale(bottom), - }); - }), - ]); -} - -function createOccurrenceMatrix( +function getHeightRangeForPBDBEntity( + d: T, units: UnitLong[], - data: Map, - axisType: ColumnAxisType = ColumnAxisType.AGE, -): OccurrenceMatrixData { - const taxonUnitMap = new Map>(); - const taxonOccurrenceMap = new Map(); - - for (const [unit_id, occurrences] of data.entries()) { - for (const occ of occurrences) { - const taxonName = occ.best_name ?? occ.taxon_name; - if (!taxonUnitMap.has(taxonName)) { - taxonUnitMap.set(taxonName, new Set()); - taxonOccurrenceMap.set(taxonName, []); - } - taxonUnitMap.get(taxonName).add(unit_id); - taxonOccurrenceMap.get(taxonName).push(occ); + axisType: ColumnAxisType, +): MeasurementHeightData | null { + let height: number | null = null; + if (d.slb != null && d.slu == "mbsf") { + // Meters below sea floor - special case for eODP where we have + // specific depth data referenced + height = Number(d.slb); + if (axisType === ColumnAxisType.DEPTH) { + // Data is already in depth units + return { height }; } } - - // sort the taxon occurrence map by number of occurrences - const sortedTaxa = Array.from(taxonUnitMap.entries()).sort((a, b) => { - // Sort alphabetically by taxon name - return b[0].localeCompare(a[0]); - }); - - const taxonRanges = new Map(); - for (const [taxonName, unitSet] of taxonUnitMap.entries()) { - taxonRanges.set( - taxonName, - accumulatePresenceDomains(units, unitSet, axisType), + if (d.unit_id == null) return null; + if (height != null) { + // If we have both height and unit info, we need to adjust the height + // to fit whatever scale type we're using. + // TODO: we could improve how this works by having concurrent age and + // height scales, which would allow us to do this without having to + // reference to a specific unit. + const unit = units.find((u) => u.unit_id === d.unit_id); + if (unit == null) return null; + const relHeight = getRelativePositionInUnit( + height, + unit, + ColumnAxisType.DEPTH, ); + if (relHeight == null) return null; + height = getPositionWithinUnit(relHeight, unit, axisType); + return { height }; } - - return { - occurrenceMap: data, - taxonUnitMap: new Map(sortedTaxa), - taxonOccurrenceMap: taxonOccurrenceMap, - taxonRanges, - }; + // We can just get the height within the unit, clipped to the unit boundaries + return standardizeMeasurementHeight({ unit_id: d.unit_id }, units, axisType); } -function accumulatePresenceDomains( - unit: UnitLong[], - presenceUnits: Set, +function getRelativePositionInUnit( + pos: number, + unit: UnitLong, axisType: ColumnAxisType, -): Array<[number, number]> { - const domains: Array<[number, number]> = []; - let currentDomain: [number, number] | null = null; - - for (const u of unit) { - if (presenceUnits.has(u.unit_id)) { - if (currentDomain == null) { - if ( - axisType == ColumnAxisType.DEPTH || - axisType == ColumnAxisType.HEIGHT - ) { - currentDomain = [u.t_pos, u.b_pos]; - } else { - currentDomain = [u.t_age, u.b_age]; - } - } else { - if ( - axisType == ColumnAxisType.DEPTH || - axisType == ColumnAxisType.HEIGHT - ) { - currentDomain[1] = u.b_pos; - } else { - currentDomain[1] = u.b_age; - } - } - } else { - if (currentDomain != null) { - domains.push(currentDomain); - currentDomain = null; - } - } - } - - if (currentDomain != null) { - domains.push(currentDomain); - } - - return domains; +): number | null { + // This is the inverse of getPositionWithinUnit + const heights = getUnitHeightRange(unit, axisType, false); + const scale = scaleLinear(heights).domain([0, 1]); + const relPos = scale.invert(pos); + if (relPos < 0 || relPos > 1) return null; + return relPos; } diff --git a/packages/column-views/src/facets/fossils/provider.ts b/packages/column-views/src/facets/fossils/provider.ts index 4b3e5b7bb..26b12ec02 100644 --- a/packages/column-views/src/facets/fossils/provider.ts +++ b/packages/column-views/src/facets/fossils/provider.ts @@ -1,4 +1,3 @@ -import { group, InternMap } from "d3-array"; import { createAPIContext, useAPIResult, @@ -7,8 +6,10 @@ import { const responseUnwrapper = (d) => d.records; +const pbdbAPIBase = "https://paleobiodb.org/data1.2"; + const pbdbAPIContext = createAPIContext({ - baseURL: "https://paleobiodb.org/data1.2", + baseURL: pbdbAPIBase, unwrapResponse: responseUnwrapper, }); @@ -17,26 +18,17 @@ export enum FossilDataType { Collections = "colls", } -export function usePBDBFossilData( - type: FossilDataType, - { col_id }, -): any[] | null { - const params = { - ms_column: col_id, - show: "full,mslink", - }; - return useAPIResult(`/${type}/list.json`, params, { - context: pbdbAPIContext, - }); -} - -export interface PBDBIdentifier { +export interface PBDBEntity { unit_id: number; col_id: number; cltn_id: number; + // For eODP, slb/slu are used to store the heights of fossil locations found in measured sections. + // They may have a more general set of uses as well but these are not currently explored. + slb?: string; // The local bed in which the fossil was found + slu?: string; // The unit of measurement used to designate the local bed } -export interface PBDBCollection extends PBDBIdentifier { +export interface PBDBCollection extends PBDBEntity { cltn_name: string; pbdb_occs: number; t_age: number; @@ -44,7 +36,7 @@ export interface PBDBCollection extends PBDBIdentifier { [key: string]: any; // Allow for additional properties } -export interface PBDBOccurrence extends PBDBIdentifier { +export interface PBDBOccurrence extends PBDBEntity { occ_id: number; cltn_id: number; taxon_name: string; @@ -86,8 +78,9 @@ async function fetchPDBDFossilData( col_id: number, type: FossilDataType, ): Promise { + // Note: show=rank does not work on training PBDB server const resp = await fetch( - `https://paleobiodb.org/data1.2/${type}/list.json?ms_column=${col_id}&show=mslink,full`, + pbdbAPIBase + `/${type}/list.json?ms_column=${col_id}&show=mslink,stratext`, ); const res = await resp.json(); return res.records.map( @@ -97,21 +90,21 @@ async function fetchPDBDFossilData( ); } -async function fetchFossilData( +async function fetchFossilData( colID: number, type: FossilDataType, -): Promise> { +): Promise { const [macrostratData, pbdbData] = await Promise.all([ fetchMacrostratFossilData(colID, type), fetchPDBDFossilData(colID, type), ]); - - const data = [...macrostratData, ...pbdbData]; - - return group(data, (d) => d.unit_id); + return [...macrostratData, ...pbdbData]; } function preprocessOccurrence(d): PBDBOccurrence { + if (d.msu == null || d.msc == null) { + return d; + } /* Preprocess data for an occurrence into a Macrostrat-like format */ // Standardize names of Macrostrat units and columns const unit_id = parseInt(d.msu.replace(/^\w+:/, "")); diff --git a/packages/column-views/src/facets/fossils/index.module.sass b/packages/column-views/src/facets/fossils/taxon-ranges.module.sass similarity index 100% rename from packages/column-views/src/facets/fossils/index.module.sass rename to packages/column-views/src/facets/fossils/taxon-ranges.module.sass diff --git a/packages/column-views/src/facets/fossils/taxon-ranges.ts b/packages/column-views/src/facets/fossils/taxon-ranges.ts new file mode 100644 index 000000000..f3e9fdcb5 --- /dev/null +++ b/packages/column-views/src/facets/fossils/taxon-ranges.ts @@ -0,0 +1,220 @@ +import hyper from "@macrostrat/hyper"; +import { FossilDataType, PBDBOccurrence, useFossilData } from "./provider"; +import { Box, useElementSize } from "@macrostrat/ui-components"; +import { group } from "d3-array"; +import { ColumnAxisType, ColumnSVG } from "@macrostrat/column-components"; +import { + useMacrostratColumnData, + useCompositeScale, +} from "../../data-provider"; +import { UnitLong } from "@macrostrat/api-types"; +import styles from "./taxon-ranges.module.sass"; +import { useRef } from "react"; + +const h = hyper.styled(styles); + +export { FossilDataType }; + +export function PBDBOccurrencesMatrix({ columnID }) { + /* A column for a matrix of taxon occurrences displayed as a table beside the main column. This will + eventually be extended with first/last occurrence markers and range bars. + */ + const data = useFossilData(columnID, FossilDataType.Occurrences); + const col = useMacrostratColumnData(); + const scale = useCompositeScale(); + + if (data == null) return null; + + const data1 = group(data, (d) => d.unit_id); + + // convert the data to a map + const occurrenceMap = new Map(data1); + + const matrix = createOccurrenceMatrix(col.units, occurrenceMap, col.axisType); + + const { taxonRanges } = matrix; + + const padding = 16; + const spacing = 16; + + const taxonEntries = Array.from(taxonRanges.entries()); + //const taxon = taxonEntries.slice(0, 50); // limit to top 50 taxa + + const width = padding * 2 + spacing * taxonEntries.length; + + return h(Box, { className: "taxon-ranges", width, height: col.totalHeight }, [ + h(TaxonOccurrenceLabels, { + taxonEntries, + padding, + spacing, + scale, + }), + h( + ColumnSVG, + { + width: padding * 2 + spacing * taxonEntries.length, + }, + h( + "g.taxa-occurrences-matrix", + taxonEntries.map(([taxonName, ranges], rowIndex) => { + const xPosition = padding + rowIndex * spacing; + return h("g", { transform: `translate(${xPosition})` }, [ + ranges.map(([top, bottom]) => { + return h("line", { + y1: scale(top), + y2: scale(bottom), + }); + }), + ]); + }), + ), + ), + ]); +} + +function TaxonOccurrenceLabels({ taxonEntries, padding, spacing, scale }) { + return h("div.taxon-labels", [ + taxonEntries.map(([taxonName, ranges], rowIndex) => { + const top = ranges[0]?.[0] ?? 0; + let topPx = scale(top) - 20; + if (topPx < 200) topPx = 0; + + return h(TaxonLabel, { + top: topPx, + left: padding + rowIndex * spacing, + taxonName, + }); + }), + ]); +} + +function TaxonLabel({ top, left, taxonName }) { + const ref = useRef(); + const textSize = useElementSize(ref); + const labelWidth = textSize?.height ?? 200; + return h( + "div.taxon-label", + { + style: { + top: `${top}px`, + marginLeft: `${left}px`, + "--label-width": `${labelWidth}px`, + }, + }, + h("div.taxon-label-inner", h("div.taxon-label-text", { ref }, taxonName)), + ); +} + +type TaxonUnitMap = Map>; + +interface OccurrenceMatrixData { + occurrenceMap: Map; // Map of unit IDs to occurrences (original data) + taxonUnitMap: TaxonUnitMap; // Map of taxon names to sets of unit IDs + taxonOccurrenceMap: Map; // Map of taxon names to occurrences + taxonRanges: Map; // Map of taxon names to [top, bottom] pixel ranges +} + +function TaxonOccurrenceEntry({ + xPosition, + ranges, + scale, + name, +}: { + xPosition: number; + units: Set; +}) { + return h("g", { transform: `translate(${xPosition})` }, [ + ranges.map(([top, bottom]) => { + return h("line", { + y1: scale(top), + y2: scale(bottom), + }); + }), + ]); +} + +function createOccurrenceMatrix( + units: UnitLong[], + data: Map, + axisType: ColumnAxisType = ColumnAxisType.AGE, +): OccurrenceMatrixData { + const taxonUnitMap = new Map>(); + const taxonOccurrenceMap = new Map(); + + for (const [unit_id, occurrences] of data.entries()) { + for (const occ of occurrences) { + const taxonName = occ.best_name ?? occ.taxon_name; + if (!taxonUnitMap.has(taxonName)) { + taxonUnitMap.set(taxonName, new Set()); + taxonOccurrenceMap.set(taxonName, []); + } + taxonUnitMap.get(taxonName).add(unit_id); + taxonOccurrenceMap.get(taxonName).push(occ); + } + } + + // sort the taxon occurrence map by number of occurrences + const sortedTaxa = Array.from(taxonUnitMap.entries()).sort((a, b) => { + // Sort alphabetically by taxon name + return b[0].localeCompare(a[0]); + }); + + const taxonRanges = new Map(); + for (const [taxonName, unitSet] of taxonUnitMap.entries()) { + taxonRanges.set( + taxonName, + accumulatePresenceDomains(units, unitSet, axisType), + ); + } + + return { + occurrenceMap: data, + taxonUnitMap: new Map(sortedTaxa), + taxonOccurrenceMap: taxonOccurrenceMap, + taxonRanges, + }; +} + +function accumulatePresenceDomains( + unit: UnitLong[], + presenceUnits: Set, + axisType: ColumnAxisType, +): Array<[number, number]> { + const domains: Array<[number, number]> = []; + let currentDomain: [number, number] | null = null; + + for (const u of unit) { + if (presenceUnits.has(u.unit_id)) { + if (currentDomain == null) { + if ( + axisType == ColumnAxisType.DEPTH || + axisType == ColumnAxisType.HEIGHT + ) { + currentDomain = [u.t_pos, u.b_pos]; + } else { + currentDomain = [u.t_age, u.b_age]; + } + } else { + if ( + axisType == ColumnAxisType.DEPTH || + axisType == ColumnAxisType.HEIGHT + ) { + currentDomain[1] = u.b_pos; + } else { + currentDomain[1] = u.b_age; + } + } + } else { + if (currentDomain != null) { + domains.push(currentDomain); + currentDomain = null; + } + } + } + + if (currentDomain != null) { + domains.push(currentDomain); + } + + return domains; +} diff --git a/packages/column-views/src/facets/index.ts b/packages/column-views/src/facets/index.ts index 152382dfc..2a98a7b33 100644 --- a/packages/column-views/src/facets/index.ts +++ b/packages/column-views/src/facets/index.ts @@ -2,4 +2,3 @@ export * from "./fossils"; export * from "./detrital-zircon"; export * from "./carbon-isotopes"; export * from "./measurements"; -export * from "./base-sample-column"; diff --git a/packages/column-views/src/facets/base-sample-column.module.sass b/packages/column-views/src/facets/measurements/base.module.sass similarity index 86% rename from packages/column-views/src/facets/base-sample-column.module.sass rename to packages/column-views/src/facets/measurements/base.module.sass index e976fc17b..8309e756f 100644 --- a/packages/column-views/src/facets/base-sample-column.module.sass +++ b/packages/column-views/src/facets/measurements/base.module.sass @@ -4,6 +4,7 @@ list-style: none border-left: 1px solid var(--column-stroke-color) margin: 1px 0 + background-color: var(--column-background-color) li display: inline &:not(:last-child):after diff --git a/packages/column-views/src/facets/measurements/base.ts b/packages/column-views/src/facets/measurements/base.ts new file mode 100644 index 000000000..75f642702 --- /dev/null +++ b/packages/column-views/src/facets/measurements/base.ts @@ -0,0 +1,215 @@ +import hyper from "@macrostrat/hyper"; +import styles from "./base.module.sass"; +import { getPositionWithinUnit, getUnitHeightRange } from "../../prepare-units"; +import { ColumnNotes } from "../../notes"; +import { UnitLong } from "@macrostrat/api-types"; +import { ColumnAxisType } from "@macrostrat/column-components"; +import type { CompositeColumnScale } from "../../units"; +const h = hyper.styled(styles); + +type GetHeightRangeFn = ( + data: T, + unit: UnitLong | null, + axisType: ColumnAxisType, +) => MeasurementHeightData; + +export interface BaseMeasurementsColumnProps { + data: T[]; + noteComponent?: any; + width?: number; + paddingLeft?: number; + className?: string; + // TODO: these props are confusing + getUnitID?: (d: T) => number | string; + isMatchingUnit?: (d: T, unit: UnitLong) => boolean; + getHeightRange?: GetHeightRangeFn; + deltaConnectorAttachment?: number; +} + +export interface ColumnMeasurementData extends MeasurementHeightData { + data: T; + id: string | number; +} + +type MeasurementPositionInformation = + | MeasurementHeightData + | { + unit_id: number; + unit_rel_pos?: number; + }; + +export function standardizeMeasurementHeight( + pos: MeasurementPositionInformation, + units: UnitLong[], + axisType: ColumnAxisType, +): MeasurementHeightData | null { + /** Get a standardized height representation from position information for + * a measurement + */ + if ("height" in pos) { + return pos; + } + const unit = units.find((u) => u.unit_id === pos.unit_id); + if (unit == null) { + return null; + } + if (pos.unit_rel_pos != null) { + const res = getPositionWithinUnit(pos.unit_rel_pos, unit, axisType); + if (res == null) return null; + return { height: res }; + } else { + const [height, top_height] = getUnitHeightRange(unit, axisType); + return { height, top_height }; + } +} + +export function mergeHeightRanges( + data: MeasurementHeightData[], + axisType: ColumnAxisType, +): MeasurementHeightData { + /** Merge multiple height ranges into a single range */ + const heights = []; + + for (const d of data) { + heights.push(d.height); + if (d.top_height != null) { + heights.push(d.top_height); + } + } + + let height: number; + let top_height: number; + if (axisType === ColumnAxisType.AGE || axisType === ColumnAxisType.DEPTH) { + height = Math.max(...heights); + top_height = Math.min(...heights); + } else { + height = Math.min(...heights); + top_height = Math.max(...heights); + } + + if (top_height === height) { + return { height }; + } + return { height, top_height }; +} + +export type MeasurementHeightData = { + height: number; + top_height?: number | null; +}; + +export function BaseMeasurementsColumn({ + data, + noteComponent, + width = 500, + paddingLeft = 40, + className, + deltaConnectorAttachment, + focusedNoteComponent, +}: BaseMeasurementsColumnProps) { + if (data == null) return null; + + return h( + "div.measurements-column", + { className }, + h(ColumnNotes, { + width, + paddingLeft, + notes: data, + noteComponent, + deltaConnectorAttachment, + focusedNoteComponent, + }), + ); +} + +interface TruncatedListProps { + data: any[]; + className?: string; + maxItems?: number; + itemRenderer?: (props: { data: any }) => any; +} + +export function TruncatedList({ + data, + className, + maxItems = 5, + itemRenderer = (p) => h("span", p.data), +}: TruncatedListProps) { + let tooMany = null; + let d1 = data; + if (data.length > maxItems) { + const n = data.length - maxItems; + d1 = data.slice(0, maxItems); + tooMany = h("li.too-many", `and ${n} more`); + } + + return h("ul.truncated-list", { className }, [ + d1.map((d, i) => { + return h("li.element", { key: i }, h(itemRenderer, { data: d })); + }), + tooMany, + ]); +} + +export function groupNotesByPixelDistance( + data: ColumnMeasurementData[], + scale: CompositeColumnScale, + axisType: ColumnAxisType, + groupDistance: number, +) { + /** Group notes that are within a certain pixel distance of each other + * in display space + */ + if (data.length === 0 || groupDistance <= 0) return data; + if (axisType === ColumnAxisType.AGE || axisType === ColumnAxisType.DEPTH) { + data.sort((a, b) => b.height - a.height); + } else { + // Sort data by height (ascending up the column) + data.sort((a, b) => a.height - b.height); + } + + const groupedData: ColumnMeasurementData[] = []; + let currentGroup: ColumnMeasurementData | null = null; + let currentGroupPosition: number = Infinity; + + for (const d of data) { + // Check distance from current max position + // Pixels go up as we go down in the section + + // The _top_ of the next group must be within groupDistance of the + // _bottom_ of the current group. This makes sure that we don't collapse + // groups that are separated by a large distance + const distance = currentGroupPosition - scale(d.top_height ?? d.height); + + if (distance <= groupDistance) { + if (currentGroup == null) { + throw new Error("Current group is null when it shouldn't be"); + } + // Merge into current group + currentGroup.data.push(...d.data); + // Update height range + const top_height = d.top_height ?? d.height; + if ( + axisType === ColumnAxisType.AGE || + axisType === ColumnAxisType.DEPTH + ) { + // Inverted axis + currentGroup.top_height = Math.min(currentGroup.top_height, top_height); + } else { + currentGroup.top_height = Math.max(currentGroup.top_height, top_height); + } + currentGroup.key = `${currentGroup.height}-${currentGroup.top_height}`; + } else { + // Start a new group + currentGroup = { ...d, data: [...d.data] }; + currentGroupPosition = + scale(currentGroup.height) ?? + scale(currentGroup.top_height) ?? + Infinity; + groupedData.push(currentGroup); + } + } + + return groupedData; +} diff --git a/packages/column-views/src/facets/measurements/index.ts b/packages/column-views/src/facets/measurements/index.ts index b04e0c8cc..a641b6cd3 100644 --- a/packages/column-views/src/facets/measurements/index.ts +++ b/packages/column-views/src/facets/measurements/index.ts @@ -1,38 +1,2 @@ -import h from "@macrostrat/hyper"; -import { useAPIResult } from "@macrostrat/ui-components"; -import { BaseMeasurementsColumn, TruncatedList } from "../base-sample-column"; - -function useSGPData({ col_id }) { - const res = useAPIResult( - "https://dev.macrostrat.org/api/pg/sgp_unit_matches", - { - col_id: `eq.${col_id}`, - }, - (d) => d, - ); - return res; -} - -export function SGPMeasurementsColumn({ columnID, color = "magenta" }) { - const data = useSGPData({ col_id: columnID }); - - if (data == null) return null; - - return h(BaseMeasurementsColumn, { - data, - noteComponent: SGPSamplesNote, - }); -} - -function SGPSamplesNote(props) { - const { note } = props; - const sgp_samples = note?.data?.sgp_samples; - - if (sgp_samples == null || sgp_samples.length === 0) return null; - - return h(TruncatedList, { - className: "sgp-samples", - data: sgp_samples, - itemRenderer: (p) => h("span", p.data.name), - }); -} +export * from "./base"; +export * from "./sgp"; diff --git a/packages/column-views/src/facets/measurements/provider.ts b/packages/column-views/src/facets/measurements/provider.ts deleted file mode 100644 index b9dff27e5..000000000 --- a/packages/column-views/src/facets/measurements/provider.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { group } from "d3-array"; -import { createAPIContext, useAPIResult } from "@macrostrat/ui-components"; - -const responseUnwrapper = (d) => d.records; - -const pbdbAPIContext = createAPIContext({ - baseURL: "https://training.paleobiodb.org/data1.2", - unwrapResponse: responseUnwrapper, -}); - -export enum FossilDataType { - Occurrences = "occs", - Collections = "colls", -} - -export function usePBDBFossilData( - type: FossilDataType, - { col_id }, -): any[] | null { - const params = { - ms_column: col_id, - show: "full,mslink", - }; - return useAPIResult(`/${type}/list.json`, params, { - context: pbdbAPIContext, - }); -} - -export interface PBDBCollection { - unit_id: number; - col_id: number; - cltn_id: number; - cltn_name: string; - pbdb_occs: number; - t_age: number; - b_age: number; - [key: string]: any; // Allow for additional properties -} - -function useMacrostratFossilData({ col_id }): PBDBCollection[] | null { - return useAPIResult("/fossils", { col_id }); -} - -function createMacrostratCollection(d): PBDBCollection { - let unit_id = null; - let col_id = null; - // Standardize names of Macrostrat units and columns - if (d.msu !== null) { - unit_id = parseInt(d.msu.replace(/^\w+:/, "")); - } - if (d.msc !== null) { - col_id = parseInt(d.msc.replace(/^\w+:/, "")); - } - - return { - ...d, - unit_id, - col_id, - cltn_id: parseInt(d.oid.replace(/^col:/, "")), - cltn_name: d.nam, - t_age: d.t_age, - b_age: d.b_age, - }; -} - -export function useFossilData({ col_id }) { - // Fossil links are stored in both Macrostrat and PBDB, depending on how the link was assembled. Here - // we create a unified view of data over both sources. - - const r1 = usePBDBFossilData(FossilDataType.Collections, { col_id }); - - const r2 = useMacrostratFossilData({ col_id }); - - if (r1 == null || r2 == null) return null; - const r1a = r1.map(createMacrostratCollection); - - const data = [...r1a, ...r2]; - - return group(data, (d) => d.unit_id); -} diff --git a/packages/column-views/src/facets/measurements/sgp.ts b/packages/column-views/src/facets/measurements/sgp.ts new file mode 100644 index 000000000..2b0800446 --- /dev/null +++ b/packages/column-views/src/facets/measurements/sgp.ts @@ -0,0 +1,91 @@ +import h from "@macrostrat/hyper"; +import { useAPIResult } from "@macrostrat/ui-components"; +import { + BaseMeasurementsColumn, + groupNotesByPixelDistance, + standardizeMeasurementHeight, + TruncatedList, +} from "./base"; +import { UnitLong } from "@macrostrat/api-types"; +import { ColumnAxisType } from "@macrostrat/column-components"; +import { + useCompositeScale, + useMacrostratColumnData, +} from "../../data-provider"; +import { CompositeColumnScale } from "../../prepare-units/composite-scale"; + +function useSGPData({ col_id }) { + const res = useAPIResult( + "https://dev.macrostrat.org/api/pg/sgp_unit_matches", + { + col_id: `eq.${col_id}`, + }, + (d) => d, + ); + return res; +} + +interface SGPSampleData { + col_id: number; + unit_id: number; + sgp_samples: { name: string; id: number }[]; +} + +export function SGPMeasurementsColumn({ columnID, color = "magenta" }) { + const data: SGPSampleData[] | null = useSGPData({ col_id: columnID }); + const { axisType, units } = useMacrostratColumnData(); + const scale = useCompositeScale(); + + if (data == null || units == null || scale == null) return null; + + const data1 = prepareSGPData(data, scale, units, axisType); + + return h(BaseMeasurementsColumn, { + data: data1, + noteComponent: SGPSamplesNote, + focusedNoteComponent: SGPSamplesNote, + }); +} + +function SGPSamplesNote(props) { + const { note, focused } = props; + const sgp_samples = note?.data; + + if (sgp_samples == null || sgp_samples.length === 0) return null; + + return h(TruncatedList, { + className: "sgp-samples", + data: sgp_samples, + itemRenderer: (p) => h("span", p.data.name), + maxItems: focused ? Infinity : 5, + }); +} + +function prepareSGPData( + data: SGPSampleData[], + scale: CompositeColumnScale, + units: UnitLong[], + axisType: ColumnAxisType, +) { + // Find matching units for samples + const d1 = data + .map((sample) => { + const data = sample.sgp_samples; + if (data == null || data.length === 0) return null; + const heightData = standardizeMeasurementHeight( + { unit_id: sample.unit_id }, + units, + axisType, + ); + if (heightData == null) return null; + data.sort((a, b) => a.id - b.id); + return { + ...heightData, + data, + id: sample.unit_id, + }; + }) + .filter(Boolean); + + return groupNotesByPixelDistance(d1, scale, axisType, 5); +} diff --git a/packages/column-views/src/notes.ts b/packages/column-views/src/notes.ts index 2b827fe09..907843dab 100644 --- a/packages/column-views/src/notes.ts +++ b/packages/column-views/src/notes.ts @@ -2,9 +2,9 @@ import h from "@macrostrat/hyper"; import { ColumnNotesProvider } from "./units"; -import { StaticNotesColumn, SVG } from "@macrostrat/column-components"; +import { NotesColumn, SVG } from "@macrostrat/column-components"; import { useCompositeScale, useMacrostratColumnData } from "./data-provider"; -import type { ReactNode } from "react"; +import type { ComponentType, ReactNode } from "react"; interface ColumnNotesProps { notes: any[]; @@ -13,6 +13,7 @@ interface ColumnNotesProps { paddingLeft?: number; deltaConnectorAttachment?: number; children?: ReactNode; + focusedNoteComponent?: ComponentType | null; } export function ColumnNotes({ @@ -21,6 +22,7 @@ export function ColumnNotes({ noteComponent, paddingLeft = 60, deltaConnectorAttachment, + focusedNoteComponent, children, }: ColumnNotesProps) { const { totalHeight } = useMacrostratColumnData(); @@ -35,12 +37,13 @@ export function ColumnNotes({ }, [ h(SVG, { width, height: totalHeight, paddingH: 4 }, [ - h(StaticNotesColumn, { + h(NotesColumn, { width, notes, noteComponent, paddingLeft, deltaConnectorAttachment, + focusedNoteComponent, }), ]), children, diff --git a/packages/column-views/src/prepare-units/index.ts b/packages/column-views/src/prepare-units/index.ts index bc5e24724..2cf33a8aa 100644 --- a/packages/column-views/src/prepare-units/index.ts +++ b/packages/column-views/src/prepare-units/index.ts @@ -57,15 +57,19 @@ export function prepareColumnUnits( scale, } = options; + let _totalHeight = null; + if (scale != null) { // Set t_age and b_age based on scale domain if not already set const domain = scale.domain(); if (axisType == ColumnAxisType.AGE) { if (t_age == null) t_age = Math.min(...domain); if (b_age == null) b_age = Math.max(...domain); + _totalHeight = Math.abs(scale(b_age) - scale(t_age)); } else { if (t_pos == null) t_pos = Math.min(...domain); if (b_pos == null) b_pos = Math.max(...domain); + _totalHeight = Math.abs(scale(b_pos) - scale(t_pos)); } } @@ -166,7 +170,9 @@ export function prepareColumnUnits( ); } - /** Prepare section scale information using groups */ + /** Prepare section scale information using groups. + * Total height is computed from section scales. + * */ let { totalHeight, sections: sections2 } = finalizeSectionHeights( sectionsWithScales, unconformityHeight, @@ -197,7 +203,7 @@ export function prepareColumnUnits( return { units: units2, - totalHeight, + totalHeight: _totalHeight ?? totalHeight, sections: sectionsOut, }; } diff --git a/packages/column-views/src/prepare-units/utils.ts b/packages/column-views/src/prepare-units/utils.ts index 81f2ad897..1fed34471 100644 --- a/packages/column-views/src/prepare-units/utils.ts +++ b/packages/column-views/src/prepare-units/utils.ts @@ -83,17 +83,63 @@ export interface PossiblyClippedUnit extends BaseUnit { export function getUnitHeightRange( unit: PossiblyClippedUnit, axisType: ColumnAxisType, + clipped: boolean = true, ): [number, number] { - switch (axisType) { - case ColumnAxisType.AGE: - return [unit.b_clip_pos ?? unit.b_age, unit.t_clip_pos ?? unit.t_age]; - case ColumnAxisType.DEPTH: - case ColumnAxisType.ORDINAL: - case ColumnAxisType.HEIGHT: - return [unit.b_clip_pos ?? unit.b_pos, unit.t_clip_pos ?? unit.t_pos]; - default: - throw new Error(`Unknown axis type: ${axisType}`); + if (clipped) { + switch (axisType) { + case ColumnAxisType.AGE: + return [unit.b_clip_pos ?? unit.b_age, unit.t_clip_pos ?? unit.t_age]; + case ColumnAxisType.DEPTH: + case ColumnAxisType.ORDINAL: + case ColumnAxisType.HEIGHT: + return [unit.b_clip_pos ?? unit.b_pos, unit.t_clip_pos ?? unit.t_pos]; + default: + throw new Error(`Unknown axis type: ${axisType}`); + } + } else { + switch (axisType) { + case ColumnAxisType.AGE: + return [unit.b_age, unit.t_age]; + case ColumnAxisType.DEPTH: + case ColumnAxisType.ORDINAL: + case ColumnAxisType.HEIGHT: + return [unit.b_pos, unit.t_pos]; + default: + throw new Error(`Unknown axis type: ${axisType}`); + } + } +} + +export function getPositionWithinUnit( + position: number, + unit: PossiblyClippedUnit, + axisType: ColumnAxisType, +): number | null { + /** Translate a relative position (0-1) within a unit to an absolute position + * within the unit's height range. If the unit is clipped, null values will be + * returned for positions outside the clip range + */ + if (position < 0 || position > 1) { + throw new Error(`Position must be between 0 and 1: ${position}`); } + + const [pos_bottom, pos_top] = getUnitHeightRange(unit, axisType, false); + const abs_pos = pos_bottom + position * (pos_top - pos_bottom); + + // If clipped, check if abs_pos is within the clipped range + const [clip_bottom, clip_top] = getUnitHeightRange(unit, axisType, true); + if (axisType === ColumnAxisType.AGE || axisType === ColumnAxisType.DEPTH) { + // Invert for age/depth axes + if (abs_pos > clip_bottom || abs_pos < clip_top) { + return null; + } + } else { + if (abs_pos < clip_bottom || abs_pos > clip_top) { + return null; + } + } + + return abs_pos; } export const createUnitSorter = (axisType: ColumnAxisType) => { diff --git a/packages/column-views/stories/column-navigation.stories.ts b/packages/column-views/stories/column-navigation.stories.ts index f5329c748..e4cf1445e 100644 --- a/packages/column-views/stories/column-navigation.stories.ts +++ b/packages/column-views/stories/column-navigation.stories.ts @@ -16,6 +16,7 @@ export default { component: ColumnStoryUI, args: { columnID: 432, + projectID: 1, axisType: "age", collapseSmallUnconformities: false, targetUnitHeight: 20, diff --git a/packages/column-views/stories/facets/pbdb.stories.ts b/packages/column-views/stories/facets/fossils.stories.ts similarity index 91% rename from packages/column-views/stories/facets/pbdb.stories.ts rename to packages/column-views/stories/facets/fossils.stories.ts index 144f95726..ee8dbf2f6 100644 --- a/packages/column-views/stories/facets/pbdb.stories.ts +++ b/packages/column-views/stories/facets/fossils.stories.ts @@ -8,7 +8,7 @@ import { } from "../../src"; import h from "@macrostrat/hyper"; import { StandaloneColumn } from "../column-ui"; -import { Meta } from "@storybook/react-vite"; +import { Meta, StoryObj } from "@storybook/react-vite"; import { ColumnAxisType } from "@macrostrat/column-components"; function PBDBFossilsDemoColumn(props) { @@ -30,8 +30,8 @@ function PBDBFossilsDemoColumn(props) { ); } -export default { - title: "Column views/Facets/Fossil occurrences", +const meta = { + title: "Column views/Facets/Fossils (via PBDB)", component: PBDBFossilsDemoColumn, tags: ["!autodocs"], argTypes: { @@ -44,9 +44,13 @@ export default { control: { type: "select" }, }, }, -} as Meta; +} satisfies Meta; -export const eODPColumn: Story = { +export default meta; + +type Story = StoryObj; + +export const eODPColumnCollections = { args: { id: 5576, inProcess: true, @@ -60,7 +64,7 @@ export const eODPColumn: Story = { }, }; -export const eODPColumnOccurrences: Story = { +export const eODPColumnTaxa = { args: { id: 5576, axisType: ColumnAxisType.DEPTH, @@ -82,7 +86,6 @@ export function eODPColumnWithTaxonRanges() { { showTimescale: false, showLabelColumn: false, - allowUnitSelection: false, id, axisType: ColumnAxisType.DEPTH, pixelScale: 20, diff --git a/packages/column-views/stories/nonlinear-scale.stories.ts b/packages/column-views/stories/nonlinear-scale.stories.ts index 9a27f7098..6e7ec6810 100644 --- a/packages/column-views/stories/nonlinear-scale.stories.ts +++ b/packages/column-views/stories/nonlinear-scale.stories.ts @@ -50,13 +50,13 @@ export const LinearScale: Story = { showTimescale: true, timescaleLevels: [1, 2], // NOTE: scale domains are clipped to the age range of the column - scale: scaleLinear().domain([0, 4500]).range([0, 1500]), + scale: scaleLinear().domain([0, 4000]).range([0, 1500]), }, }; // Logarithmic age scale -const logScale = scaleLog().base(10).domain([0.001, 4500]).range([0, 1000]); +const logScale = scaleLog().base(10).domain([0.001, 4000]).range([0, 1000]); export const LogScale: Story = { args: { @@ -81,6 +81,7 @@ export const PowerScale: Story = { axisType: ColumnAxisType.AGE, showLabels: false, unitComponent: MinimalUnit, + showUnitPopover: true, showTimescale: true, timescaleLevels: [1, 2], scale: powScale, diff --git a/packages/style-system/CHANGELOG.md b/packages/style-system/CHANGELOG.md index 040ad37d2..0cbcd4ece 100644 --- a/packages/style-system/CHANGELOG.md +++ b/packages/style-system/CHANGELOG.md @@ -4,6 +4,10 @@ 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). +## [0.2.4] - 2025-12-10 + +- Upgraded Vite dependency + ## [0.2.3] - 2025-08-22 - Adjusted box shadow color for dark mode diff --git a/packages/style-system/package.json b/packages/style-system/package.json index 8fd0d5fd9..eab9ce526 100644 --- a/packages/style-system/package.json +++ b/packages/style-system/package.json @@ -1,6 +1,6 @@ { "name": "@macrostrat/style-system", - "version": "0.2.3", + "version": "0.2.4", "description": "Style system for Macrostrat", "main": "dist/style-system.css", "source": "src/index.ts",