Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 19 additions & 15 deletions packages/base/src/mainview/mainView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ import { MobileSpectaPanel } from '../panelview/story-maps/MobileSpectaPanel';
import StoryViewerPanel, {
IStoryViewerPanelHandle,
} from '../panelview/story-maps/StoryViewerPanel';
import SpectaPresentationProgressBar from '../statusbar/SpectaPresentationProgressBar';

type OlLayerTypes =
| TileLayer
Expand Down Expand Up @@ -2779,22 +2780,25 @@ 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
ref={this.spectaContainerRef}
className="jgis-specta-story-panel-container"
>
<StoryViewerPanel
ref={this.storyViewerPanelRef}
model={this._model}
isSpecta={this.state.isSpectaPresentation}
className="jgis-story-viewer-panel-specta-mod"
onSegmentTransitionEnd={() =>
this._clearStoryScrollGuard()
}
/>
<>
<div className="jgis-specta-right-panel-container-mod jgis-right-panel-container">
<div
ref={this.spectaContainerRef}
className="jgis-specta-story-panel-container"
>
<StoryViewerPanel
ref={this.storyViewerPanelRef}
model={this._model}
isSpecta={this.state.isSpectaPresentation}
className="jgis-story-viewer-panel-specta-mod"
onSegmentTransitionEnd={() =>
this._clearStoryScrollGuard()
}
/>
</div>
</div>
</div>
<SpectaPresentationProgressBar model={this._model} />
</>
))
)}
</div>
Expand Down
66 changes: 35 additions & 31 deletions packages/base/src/panelview/story-maps/StoryViewerPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,8 @@ const StoryViewerPanel = forwardRef<
},
ref,
) => {
const [currentIndexDisplayed, setCurrentIndexDisplayed] = useState(() =>
model.getCurrentSegmentIndex(),
const [currentIndex, setCurrentIndex] = useState(
() => model.getCurrentSegmentIndex() ?? 0,
);
const [storyData, setStoryData] = useState<IJGISStoryMap | null>(
model.getSelectedStory().story ?? null,
Expand All @@ -112,10 +112,19 @@ const StoryViewerPanel = forwardRef<
const atTopRef = useRef(false);
const atBottomRef = useRef(false);

useEffect(() => {
const onIndexChanged = (_: IJupyterGISModel, index: number) => {
setCurrentIndex(Math.max(0, index ?? 0));
};
model.currentSegmentIndexChanged.connect(onIndexChanged);
return () => {
model.currentSegmentIndexChanged.disconnect(onIndexChanged);
};
}, [model]);

const setIndex = useCallback(
(index: number) => {
model.setCurrentSegmentIndex(index);
setCurrentIndexDisplayed(index);
},
[model],
);
Expand Down Expand Up @@ -151,10 +160,10 @@ const StoryViewerPanel = forwardRef<
.filter((layer): layer is IJGISLayer => layer !== undefined);
}, [storyData, model]);

// Derive current story segment from story segments and currentIndexDisplayed
// Derive current story segment from story segments and currentIndex
const currentStorySegment = useMemo(() => {
return storySegments[currentIndexDisplayed];
}, [storySegments, currentIndexDisplayed]);
return storySegments[currentIndex];
}, [storySegments, currentIndex]);

// Derive active slide and layer name from current story segment
const activeSlide = useMemo(() => {
Expand All @@ -168,11 +177,11 @@ const StoryViewerPanel = forwardRef<

// Derive story segment ID for zooming
const currentStorySegmentId = useMemo(() => {
return storyData?.storySegments?.[currentIndexDisplayed];
}, [storyData, currentIndexDisplayed]);
return storyData?.storySegments?.[currentIndex];
}, [storyData, currentIndex]);

const hasPrev = currentIndexDisplayed > 0;
const hasNext = currentIndexDisplayed < storySegments.length - 1;
const hasPrev = currentIndex > 0;
const hasNext = currentIndex < storySegments.length - 1;

const zoomToCurrentLayer = () => {
if (currentStorySegmentId) {
Expand Down Expand Up @@ -271,25 +280,20 @@ const StoryViewerPanel = forwardRef<

// Set selected layer and apply symbology when segment changes; remove previous segment's override layers first.
useEffect(() => {
if (!storyData?.storySegments || currentIndexDisplayed < 0) {
if (!storyData?.storySegments || currentIndex < 0) {
return;
}
clearOverrideLayers();
setSelectedLayerByIndex(currentIndexDisplayed);
overrideSymbology(currentIndexDisplayed);
}, [
storyData,
currentIndexDisplayed,
setSelectedLayerByIndex,
clearOverrideLayers,
]);
setSelectedLayerByIndex(currentIndex);
overrideSymbology(currentIndex);
}, [storyData, currentIndex, setSelectedLayerByIndex, clearOverrideLayers]);

// Set selected layer on initial render and when story data changes
useEffect(() => {
if (storyData?.storySegments && currentIndexDisplayed >= 0) {
setSelectedLayerByIndex(currentIndexDisplayed);
if (storyData?.storySegments && currentIndex >= 0) {
setSelectedLayerByIndex(currentIndex);
}
}, [storyData, currentIndexDisplayed, setSelectedLayerByIndex]);
}, [storyData, currentIndex, setSelectedLayerByIndex]);

// Apply story presentation colors (specta) to panel root
useEffect(() => {
Expand Down Expand Up @@ -426,15 +430,15 @@ const StoryViewerPanel = forwardRef<

const handlePrev = useCallback(() => {
if (hasPrev) {
setIndex(currentIndexDisplayed - 1);
setIndex(currentIndex - 1);
}
}, [currentIndexDisplayed, setIndex]);
}, [currentIndex, setIndex]);

const handleNext = useCallback(() => {
if (hasNext) {
setIndex(currentIndexDisplayed + 1);
setIndex(currentIndex + 1);
}
}, [currentIndexDisplayed, storySegments.length, setIndex]);
}, [currentIndex, storySegments.length, setIndex]);

if (!storyData || storyData?.storySegments?.length === 0) {
return (
Expand Down Expand Up @@ -474,7 +478,7 @@ const StoryViewerPanel = forwardRef<
observer.observe(topEl);
observer.observe(bottomEl);
return () => observer.disconnect();
}, [currentIndexDisplayed]);
}, [currentIndex]);

// Expose methods via ref for parent component to use
useImperativeHandle(
Expand Down Expand Up @@ -523,7 +527,7 @@ const StoryViewerPanel = forwardRef<
};
el.addEventListener('animationend', handleAnimationEnd);
return () => el.removeEventListener('animationend', handleAnimationEnd);
}, [currentIndexDisplayed, onSegmentTransitionEnd]);
}, [currentIndex, onSegmentTransitionEnd]);

return (
<div
Expand All @@ -539,22 +543,22 @@ const StoryViewerPanel = forwardRef<
/>
<div
ref={segmentContainerRef}
key={currentIndexDisplayed}
key={currentIndex}
className="jgis-story-segment-container"
style={{
animationDuration: `${transitionTime}s`,
}}
>
<div id="jgis-story-segment-header">
<h1 className="jgis-story-viewer-title">
{layerName ?? `Slide ${currentIndexDisplayed + 1}`}
{layerName ?? `Slide ${currentIndex + 1}`}
</h1>
{activeSlide?.content?.image && imageLoaded ? (
<StoryImageSection
imageUrl={activeSlide.content.image}
imageLoaded={imageLoaded}
layerName={layerName ?? ''}
slideNumber={currentIndexDisplayed}
slideNumber={currentIndex}
navSlot={navPlacement === 'over-image' ? navSlot : null}
/>
) : (
Expand Down
87 changes: 87 additions & 0 deletions packages/base/src/statusbar/SpectaPresentationProgressBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { IJupyterGISModel } from '@jupytergis/schema';
import React, { useEffect, useRef, useState } from 'react';

interface ISpectaPresentationProgressBarProps {
model: IJupyterGISModel;
}

function SpectaPresentationProgressBar({
model,
}: ISpectaPresentationProgressBarProps) {
const segmentCount =
model.getSelectedStory().story?.storySegments?.length ?? 0;
const [currentIndex, setCurrentIndex] = useState(() =>
Math.max(0, model.getCurrentSegmentIndex() ?? 0),
);

useEffect(() => {
const onIndexChanged = (_: IJupyterGISModel, index: number) => {
setCurrentIndex(Math.max(0, index ?? 0));
};
model.currentSegmentIndexChanged.connect(onIndexChanged);
return () => {
model.currentSegmentIndexChanged.disconnect(onIndexChanged);
};
}, [model]);

const safeCount = Math.max(0, segmentCount);
const clampedIndex =
safeCount > 0 ? Math.min(currentIndex, safeCount - 1) : 0;

const prevIndexRef = useRef(clampedIndex);
const [direction, setDirection] = useState<'next' | 'prev' | null>(null);

useEffect(() => {
const prev = prevIndexRef.current;
if (clampedIndex !== prev) {
setDirection(clampedIndex > prev ? 'next' : 'prev');
prevIndexRef.current = clampedIndex;
}
}, [clampedIndex]);

const { story } = model.getSelectedStory();
const segmentIds = story?.storySegments ?? [];
const currentSegmentId = segmentIds[clampedIndex];
const currentSegment = currentSegmentId
? model.getLayer(currentSegmentId)
: undefined;
const segmentParams = currentSegment?.parameters as
| { transition?: { time?: number } }
| undefined;
const transitionTime = segmentParams?.transition?.time ?? 0.3;

return (
<div
className="jgis-specta-progress"
data-direction={direction ?? undefined}
style={
{
'--jgis-specta-transition-duration': `${transitionTime}s`,
} as React.CSSProperties
}
>
<div className="jgis-specta-progress-bar">
{Array.from({ length: safeCount }, (_, i) => safeCount - 1 - i).map(
segmentIndex => (
<div
key={segmentIndex}
className="jgis-specta-bar-segment"
data-filled={segmentIndex <= clampedIndex ? '' : undefined}
style={{ '--segment-index': segmentIndex } as React.CSSProperties}
>
<button
type="button"
className="jgis-specta-progress-input"
onClick={() => model.setCurrentSegmentIndex(segmentIndex)}
aria-label={`Segment ${segmentIndex + 1} of ${safeCount}`}
aria-pressed={segmentIndex === clampedIndex}
/>
</div>
),
)}
</div>
</div>
);
}

export default SpectaPresentationProgressBar;
3 changes: 2 additions & 1 deletion packages/base/style/base.css
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
@import url('./filterPanel.css');
@import url('./symbologyDialog.css');
@import url('./statusBar.css');
@import url('./spectaProgressBar.css');
@import url('./temporalSlider.css');
@import url('./tabPanel.css');
@import url('./stacBrowser.css');
Expand Down Expand Up @@ -141,7 +142,7 @@ button.jp-mod-styled.jp-mod-reject {
gap: 0.5rem;
position: absolute;
bottom: 0.125rem;
z-index: 1000;
z-index: 1;
padding: 0 0.125rem;
}

Expand Down
Loading
Loading