Skip to content
Merged
13 changes: 3 additions & 10 deletions packages/base/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,15 +64,6 @@
"@lumino/widgets": "^2.0.0",
"@mapbox/vector-tile": "^2.0.3",
"@naisutech/react-tree": "^3.0.1",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-toggle-group": "^1.1.10",
"@rjsf/core": "^4.2.0",
"@rjsf/validator-ajv8": "^5.23.1",
"ajv": "^8.14.0",
Expand All @@ -94,14 +85,16 @@
"pmtiles": "^3.0.7",
"proj4": "2.19.3",
"proj4-list": "1.0.4",
"radix-ui": "^1.4.3",
"react": "^18.0.1",
"react-day-picker": "^9.7.0",
"react-markdown": "^10.1.0",
"shpjs": "^6.1.0",
"styled-components": "^5.3.6",
"three": "^0.135.0",
"three-mesh-bvh": "^0.5.17",
"uuid": "^11.0.3"
"uuid": "^11.0.3",
"vaul": "^1.1.2"
},
"devDependencies": {
"@apidevtools/json-schema-ref-parser": "^9.0.9",
Expand Down
10 changes: 5 additions & 5 deletions packages/base/src/formbuilder/objectform/StoryEditorForm.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { IDict } from '@jupytergis/schema';
import { RJSFSchema, UiSchema } from '@rjsf/utils';

import { BaseForm } from './baseform';
import { getCssVarAsColor } from '@/src/tools';
import { BaseForm } from './baseform';

/**
* The form to modify story map properties.
Expand All @@ -20,7 +20,7 @@ export class StoryEditorPropertiesForm extends BaseForm {
'ui:widget': 'color',
};

uiSchema.presentaionTextColor = {
uiSchema.presentationTextColor = {
'ui:widget': 'color',
};

Expand All @@ -36,12 +36,12 @@ export class StoryEditorPropertiesForm extends BaseForm {
}
}
if (
schemaProps?.presentaionTextColor &&
data?.presentaionTextColor === undefined
schemaProps?.presentationTextColor &&
data?.presentationTextColor === undefined
) {
const defaultText = getCssVarAsColor('--jp-ui-font-color0');
if (defaultText) {
schemaProps.presentaionTextColor.default = defaultText;
schemaProps.presentationTextColor.default = defaultText;
}
}
}
Expand Down
42 changes: 35 additions & 7 deletions packages/base/src/mainview/mainView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ import * as React from 'react';
import AnnotationFloater from '@/src/annotations/components/AnnotationFloater';
import { CommandIDs } from '@/src/constants';
import { LoadingOverlay } from '@/src/shared/components/loading';
import useMediaQuery from '@/src/shared/hooks/useMediaQuery';
import StatusBar from '@/src/statusbar/StatusBar';
import { debounce, isLightTheme, loadFile, throttle } from '@/src/tools';
import CollaboratorPointers, { ClientPointer } from './CollaboratorPointers';
Expand All @@ -120,6 +121,7 @@ import { MainViewModel } from './mainviewmodel';
import { hexToRgb } from '../dialogs/symbology/colorRampUtils';
import { markerIcon } from '../icons';
import { LeftPanel, RightPanel } from '../panelview';
import { MobileSpectaPanel } from '../panelview/story-maps/MobileSpectaPanel';
import StoryViewerPanel, {
IStoryViewerPanelHandle,
} from '../panelview/story-maps/StoryViewerPanel';
Expand All @@ -138,6 +140,8 @@ interface IProps {
state?: IStateDB;
formSchemaRegistry?: IJGISFormSchemaRegistry;
annotationModel?: IAnnotationModel;
/** True when viewport matches (max-width: 768px). Injected by MainViewWithMediaQuery. */
isMobile?: boolean;
}

interface IStates {
Expand Down Expand Up @@ -2235,7 +2239,7 @@ export class MainView extends React.Component<IProps, IStates> {

const story = this._model.getSelectedStory().story;
const bgColor = story?.presentationBgColor;
const textColor = story?.presentaionTextColor;
const textColor = story?.presentationTextColor;

// Set background color
if (bgColor) {
Expand Down Expand Up @@ -2364,6 +2368,16 @@ export class MainView extends React.Component<IProps, IStates> {
const layerParams = jgisLayer.parameters as IStorySegmentLayer;
const coords = getCenter(layerParams.extent);

// Don't move map if we're already centered on the segment
const viewCenter = this._Map.getView().getCenter();
const centersEqual =
viewCenter !== undefined &&
Math.abs(viewCenter[0] - coords[0]) < 1e-9 &&
Math.abs(viewCenter[1] - coords[1]) < 1e-9;
if (centersEqual) {
return;
}

this._flyToPosition(
{ x: coords[0], y: coords[1] },
layerParams.zoom,
Expand Down Expand Up @@ -2778,6 +2792,8 @@ export class MainView extends React.Component<IProps, IStates> {
/>
)}
</>
) : this.props.isMobile ? (
<MobileSpectaPanel model={this._model} />
) : (
<div className="jgis-specta-right-panel-container-mod jgis-right-panel-container">
<div
Expand All @@ -2788,6 +2804,7 @@ export class MainView extends React.Component<IProps, IStates> {
ref={this.storyViewerPanelRef}
model={this._model}
isSpecta={this.state.isSpectaPresentation}
className="jgis-story-viewer-panel-specta-mod"
/>
</div>
</div>
Expand All @@ -2799,12 +2816,14 @@ export class MainView extends React.Component<IProps, IStates> {
></div>
</div>
</div>
<StatusBar
jgisModel={this._model}
loading={this.state.loadingLayer}
projection={this.state.viewProjection}
scale={this.state.scale}
/>
{!this.state.isSpectaPresentation && (
<StatusBar
jgisModel={this._model}
loading={this.state.loadingLayer}
projection={this.state.viewProjection}
scale={this.state.scale}
/>
)}
</div>
</>
);
Expand Down Expand Up @@ -2837,3 +2856,12 @@ export class MainView extends React.Component<IProps, IStates> {
private _isSpectaPresentationInitialized = false;
private _storyScrollHandler: ((e: Event) => void) | null = null;
}

// ! TODO make mainview a modern react component instead of a class
/** Thin wrapper that injects isMobile from useMediaQuery so MainView can use it in JSX. */
function MainViewWithMediaQuery(props: IProps) {
const isMobile = useMediaQuery('(max-width: 768px)');
return <MainView {...props} isMobile={isMobile} />;
}

export { MainViewWithMediaQuery };
4 changes: 2 additions & 2 deletions packages/base/src/mainview/mainviewwidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { ReactWidget } from '@jupyterlab/apputils';
import { IStateDB } from '@jupyterlab/statedb';
import * as React from 'react';

import { MainView } from './mainView';
import { MainViewWithMediaQuery } from './mainView';
import { MainViewModel } from './mainviewmodel';

export interface IOptions {
Expand All @@ -26,7 +26,7 @@ export class JupyterGISMainViewPanel extends ReactWidget {

render(): JSX.Element {
return (
<MainView
<MainViewWithMediaQuery
state={this._state}
viewModel={this._options.mainViewModel}
formSchemaRegistry={this._options.formSchemaRegistry}
Expand Down
173 changes: 173 additions & 0 deletions packages/base/src/panelview/story-maps/MobileSpectaPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { IJupyterGISModel } from '@jupytergis/schema';
import React, { CSSProperties, useEffect, useState } from 'react';

import { Button } from '@/src/shared/components/Button';
import {
Drawer,
DrawerContent,
DrawerTrigger,
} from '@/src/shared/components/Drawer';
import StoryViewerPanel from './StoryViewerPanel';

const MAIN_ID = 'jp-main-content-panel';
const SEGMENT_PANEL_ID = 'jgis-story-segment-panel';
const SEGMENT_HEADER_ID = 'jgis-story-segment-header';

const SNAP_FIRST_MIN = 0.3;
const SNAP_FIRST_MAX = 0.95;
const SNAP_FIRST_DEFAULT = 0.7;
/** Offset (px) for segment header height: margins from p and h1 in story content */
const SEGMENT_HEADER_OFFSET_PX = 16.8 * 2 + 18.76;

interface IMobileSpectaPanelProps {
model: IJupyterGISModel;
}

/**
* Compute the first snap point so that vaul's --snap-point-height (the
* transform offset) equals #jgis-story-segment-panel height minus #jgis-story-segment-header height.
* For a bottom drawer, offset = mainHeight * (1 - snapPoint), so
* snapPoint = (mainHeight - offset) / mainHeight.
*/
function getFirstSnapFromSegmentHeader(
mainEl: HTMLElement,
segmentPanelEl: HTMLElement,
segmentHeaderEl: HTMLElement,
): number {
const mainHeight = mainEl.getBoundingClientRect().height;
const segmentPanelHeight = segmentPanelEl.getBoundingClientRect().height;
const segmentHeaderHeight = segmentHeaderEl.getBoundingClientRect().height;
const offsetPx =
segmentPanelHeight - segmentHeaderHeight - SEGMENT_HEADER_OFFSET_PX;

if (mainHeight <= 0) {
return SNAP_FIRST_DEFAULT;
}

const fraction = (mainHeight - offsetPx) / mainHeight;
const clamped = Math.max(SNAP_FIRST_MIN, Math.min(SNAP_FIRST_MAX, fraction));
return clamped;
}

/** Build inline styles for specta presentation (bg and text color from story). */
function getSpectaPresentationStyle(model: IJupyterGISModel): CSSProperties {
const story = model.getSelectedStory().story;
const bgColor = story?.presentationBgColor;
const textColor = story?.presentationTextColor;

const style: CSSProperties = {};
if (bgColor) {
(style as Record<string, string>)['--jgis-specta-bg-color'] = bgColor;
style.backgroundColor = bgColor;
}
if (textColor) {
(style as Record<string, string>)['--jgis-specta-text-color'] = textColor;
style.color = textColor;
}
return style;
}

export function MobileSpectaPanel({ model }: IMobileSpectaPanelProps) {
const [container, setContainer] = useState<HTMLElement | null>(null);
const [snapPoints, setSnapPoints] = useState<number[]>([
SNAP_FIRST_DEFAULT,
1,
]);
const [snap, setSnap] = useState<number | string | null>(snapPoints[0]);

const presentationStyle = getSpectaPresentationStyle(model);

// Keep active snap in sync with snapPoints so Vaul's --snap-point-height stays defined.
useEffect(() => {
const isInSnapPoints = snapPoints.some(
p =>
p === snap ||
(typeof p === 'number' &&
typeof snap === 'number' &&
Math.abs(p - snap) < 1e-9),
);
if (!isInSnapPoints && snapPoints.length > 0) {
setSnap(snapPoints[0]);
}
}, [snapPoints, snap]);

// Observe #jgis-story-segment-panel (and re-attach when drawer reopens).
useEffect(() => {
const mainEl = document.getElementById(MAIN_ID);
setContainer(mainEl);

if (!mainEl) {
return;
}

const updateFirstSnap = () => {
const segmentPanelEl = document.getElementById(SEGMENT_PANEL_ID);
const segmentHeaderEl = document.getElementById(SEGMENT_HEADER_ID);

if (segmentPanelEl && segmentHeaderEl) {
const firstSnap = getFirstSnapFromSegmentHeader(
mainEl,
segmentPanelEl,
segmentHeaderEl,
);
setSnapPoints([firstSnap, 1]);
}
};

const resizeObserver = new ResizeObserver(() => updateFirstSnap());
let observedPanelEl: HTMLElement | null = null;

const syncHeaderObserver = () => {
const segmentPanelEl = document.getElementById(SEGMENT_PANEL_ID);
const segmentHeaderEl = document.getElementById(SEGMENT_HEADER_ID);

if (
!segmentPanelEl ||
!segmentHeaderEl ||
segmentPanelEl === observedPanelEl
) {
return;
}

if (observedPanelEl) {
resizeObserver.unobserve(observedPanelEl);
}
resizeObserver.observe(segmentPanelEl);
observedPanelEl = segmentPanelEl;
updateFirstSnap();
};

syncHeaderObserver();

const mutationObserver = new MutationObserver(syncHeaderObserver);
mutationObserver.observe(mainEl, {
childList: true,
subtree: true,
});

return () => {
resizeObserver.disconnect();
mutationObserver.disconnect();
};
}, []);

return (
<div className="jgis-mobile-specta-trigger-wrapper">
<Drawer
snapPoints={snapPoints}
activeSnapPoint={snap}
setActiveSnapPoint={setSnap}
direction="bottom"
container={container}
noBodyStyles={true}
>
<DrawerTrigger asChild>
<Button>Open Story Panel</Button>
</DrawerTrigger>
<DrawerContent style={presentationStyle}>
<StoryViewerPanel isSpecta={true} isMobile={true} model={model} />
</DrawerContent>
</Drawer>
</div>
);
}
Loading
Loading