Skip to content

Commit 290cbe6

Browse files
committed
feat(admin): improve interview map navigation and trip visualization
Map Navigation Improvements: - Fix interview navigation buttons disappearing after first use by maintaining list visibility - Improve prev/next interview UUID handling with direct parameter passing and DOM fallback - Initialize interview list as visible by default - Highlight currently selected interview in the list with blue background Trip Path Visualization Enhancements: - Use person-specific colors for trip paths by accessing deserialized survey objects - Create personColorMap to efficiently retrieve person colors from surveyObjectsAndAudits - Pass personColor as separate property instead of overriding color attribute - Make arrow pattern more pronounced by doubling percentFromCenter (more pointy). When not selected, the small width made it difficult to see the arrow direction. Map Rendering: - Add maxZoom: 17 limit to fitBounds operations to prevent blurry tiles at high zoom Code: - run yarn format to fix formatting issues - Improve code formatting and readability in InterviewMap and InterviewStats - Add error logging for trips without origin/destination (will be replaced by an audit)
1 parent 98d23be commit 290cbe6

File tree

8 files changed

+147
-92
lines changed

8 files changed

+147
-92
lines changed

locales/en/admin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,6 @@
150150
"MissingUuidSurvey": "The interview UUID is missing. Please check the URL or contact support.",
151151
"FitBounds": "Fit bounds to show all places and trips",
152152
"MapLayerSelect": "Map layer",
153-
"MapLayerOSM": "OSM tiles",
153+
"MapLayerOSM": "OpenStreetMap tiles",
154154
"MapLayerAerial": "Aerial imagery"
155155
}

locales/fr/admin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,6 @@
150150
"MissingUuidSurvey": "L'entrevue n'a pas d'UUID. Veuillez contacter les administrateurs de l'enquête.",
151151
"FitBounds": "Ajuster les limites pour afficher tous les lieux et déplacements",
152152
"MapLayerSelect": "Couche de carte",
153-
"MapLayerOSM": "Tuiles OSM",
153+
"MapLayerOSM": "Tuiles OpenStreetMap",
154154
"MapLayerAerial": "Photos aériennes"
155155
}

packages/evolution-frontend/src/components/admin/pages/ReviewPage.tsx

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -22,31 +22,36 @@ const ReviewPage = () => {
2222
const { t } = useTranslation();
2323
const dispatch = useDispatch<ThunkDispatch<RootState, unknown, SurveyAction>>();
2424
const interview = useSelector((state: RootState) => state.survey.interview);
25-
const [showInterviewList, setShowInterviewList] = useState(false);
25+
const [showInterviewList, setShowInterviewList] = useState(true);
2626
const [prevInterviewUuid, setPrevInterviewUuid] = useState<string | undefined>(undefined);
2727
const [nextInterviewUuid, setNextInterviewUuid] = useState<string | undefined>(undefined);
2828

2929
const handleInterviewSummaryChange = useCallback(
30-
(interviewUuid: string | null) => {
30+
(interviewUuid: string | null, prevUuid?: string, nextUuid?: string) => {
3131
if (interviewUuid) {
32-
// FIXME The next/previous interviews come from the list. That
33-
// makes the last/first interview of the _list_ the last/first
34-
// total, but there could be pages. It should be handled
35-
// differently.
36-
const listButton = document.getElementById(`interviewButtonList_${interviewUuid}`);
37-
if (!listButton) {
38-
// The filter probably changed, reset to null
39-
setPrevInterviewUuid(undefined);
40-
setNextInterviewUuid(undefined);
41-
return;
32+
// If prev/next UUIDs are provided (called from list), use them directly
33+
if (prevUuid !== undefined || nextUuid !== undefined) {
34+
setPrevInterviewUuid(prevUuid || undefined);
35+
setNextInterviewUuid(nextUuid || undefined);
36+
} else {
37+
// If no prev/next UUIDs provided (called from navigation buttons),
38+
// try to extract them from the DOM as fallback
39+
const listButton = document.getElementById(`interviewButtonList_${interviewUuid}`);
40+
if (listButton) {
41+
const domPrevUuid = listButton.getAttribute('data-prev-uuid');
42+
const domNextUuid = listButton.getAttribute('data-next-uuid');
43+
setPrevInterviewUuid(domPrevUuid === null || domPrevUuid === '' ? undefined : domPrevUuid);
44+
setNextInterviewUuid(domNextUuid === null || domNextUuid === '' ? undefined : domNextUuid);
45+
} else {
46+
// If DOM element not found, clear the navigation
47+
setPrevInterviewUuid(undefined);
48+
setNextInterviewUuid(undefined);
49+
}
4250
}
43-
const prevUuid = listButton.getAttribute('data-prev-uuid');
44-
const nextUuid = listButton.getAttribute('data-next-uuid');
4551

4652
dispatch(
4753
startSetSurveyCorrectedInterview(interviewUuid, () => {
48-
setPrevInterviewUuid(prevUuid === null ? undefined : prevUuid);
49-
setNextInterviewUuid(nextUuid === null ? undefined : nextUuid);
54+
// UUIDs are already set above
5055
})
5156
);
5257
} else {

packages/evolution-frontend/src/components/admin/validations/AnimatedArrowPathExtension.tsx

Lines changed: 40 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ export default class AnimatedArrowPathExtension extends LayerExtension {
5555
});
5656
}
5757

58-
getStartOffsetRatios(this: Layer<_AnimatedArrowPathLayerProps>, path): number[] {
58+
getStartOffsetRatios(this: Layer<_AnimatedArrowPathLayerProps>, path: number[] | number[][]): number[] {
5959
const result = [0] as number[];
6060
if (path === undefined || path.length < 2) {
6161
return result;
@@ -64,12 +64,14 @@ export default class AnimatedArrowPathExtension extends LayerExtension {
6464
const isNested = Array.isArray(path[0]);
6565
const geometrySize = isNested ? path.length : path.length / positionSize;
6666
let sumLength = 0;
67-
let p;
68-
let prevP;
67+
let p: number[];
68+
let prevP: number[] | undefined;
6969
for (let i = 0; i < geometrySize; i++) {
70-
p = isNested ? path[i] : path.slice(i * positionSize, i * positionSize + positionSize);
70+
p = isNested
71+
? (path as number[][])[i]
72+
: (path as number[]).slice(i * positionSize, i * positionSize + positionSize);
7173
p = this.projectPosition(p);
72-
if (i > 0) {
74+
if (i > 0 && prevP !== undefined) {
7375
const distance = vec3.dist(prevP, p);
7476
if (i < geometrySize - 1) {
7577
result[i] = result[i - 1] + distance;
@@ -84,7 +86,7 @@ export default class AnimatedArrowPathExtension extends LayerExtension {
8486
return result;
8587
}
8688

87-
getLengthRatios(this: Layer<AnimatedArrowPathProps>, path): number[] {
89+
getLengthRatios(this: Layer<AnimatedArrowPathProps>, path: number[] | number[][]): number[] {
8890
const result = [] as number[];
8991
if (path === undefined || path.length < 2) {
9092
return result;
@@ -93,10 +95,14 @@ export default class AnimatedArrowPathExtension extends LayerExtension {
9395
const isNested = Array.isArray(path[0]);
9496
const geometrySize = isNested ? path.length : path.length / positionSize;
9597
let sumLength = 0;
96-
let p;
97-
let prevP = this.projectPosition(isNested ? path[0] : path.slice(0, positionSize));
98+
let p: number[];
99+
let prevP: number[] = this.projectPosition(
100+
isNested ? (path as number[][])[0] : (path as number[]).slice(0, positionSize)
101+
);
98102
for (let i = 1; i < geometrySize; i++) {
99-
p = isNested ? path[i] : path.slice(i * positionSize, i * positionSize + positionSize);
103+
p = isNested
104+
? (path as number[][])[i]
105+
: (path as number[]).slice(i * positionSize, i * positionSize + positionSize);
100106
p = this.projectPosition(p);
101107
const distance = vec3.dist(prevP, p);
102108
sumLength += distance;
@@ -110,33 +116,37 @@ export default class AnimatedArrowPathExtension extends LayerExtension {
110116
return result;
111117
}
112118

113-
draw(this: Layer<_AnimatedArrowPathLayerProps>, _params: any, _extension: this) {
119+
draw(this: Layer<_AnimatedArrowPathLayerProps>, _params: Record<string, unknown>, _extension: this) {
114120
const zoom = this.context.viewport?.zoom || 14;
115121

116-
// Here is a good approximation of the zoom factor, based on map/zoom theory: Math.pow(2, zoom - 14)
117-
// Multiplier adjusts for low zoom being too fast visually even if speed was the same.
118-
const multiplier = (199 - 9 * zoom) / 19; // 10.0 times slower at zoom 1, equal speed at zoom 20.
119-
const zoomFactor = multiplier * Math.pow(2, zoom - 14);
122+
// Arrow spacing in shader units - this is how far apart arrows are
123+
const arrowSpacing = this.props.arrowSpacing || 30.0;
120124

121-
// Calculate animation time with seamless reset
122-
// Use a cycle that matches the arrow spacing to ensure seamless looping
123-
const arrowSpacing = this.props.arrowSpacing || 30.0; // Configurable arrow spacing (f32)
125+
// Zoom factor calculation
126+
// Multiplier adjusts for low zoom being too fast visually
127+
const multiplier = (199 - 9 * zoom) / 19; // 10x slower at zoom 1, 1x at zoom 20
128+
// See for exact formula: https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames#Resolution_and_Scale
129+
const zoomFactor = multiplier * Math.pow(2, zoom - 15.6);
124130

125-
// Calculate cycle duration to create seamless loops
126-
// The cycle should complete exactly when one arrow spacing cycle finishes
127-
const baseCycleDuration = 90;
128-
const seamlessCycleDuration = baseCycleDuration * arrowSpacing; // Adjust for arrow spacing pattern
131+
// Original speed calculation to maintain correct visual speed
132+
const baseSpeed = 0.01; // Shader units per second before zoom adjustment, gives a good average speed
133+
const adjustedSpeed = baseSpeed / zoomFactor;
129134

130-
const rawTime = (performance.now() / 100) % seamlessCycleDuration;
131-
const normalizedTime = rawTime / (10 * seamlessCycleDuration);
135+
// Fixed cycle duration - there will be a shift every 60 seconds when time wraps
136+
// The shift amount depends on zoom level and is generally acceptable for this use case
137+
const cycleDuration = 60.0; // seconds
132138

133-
// Scale the time to match the arrow pattern
134-
const seamlessTime = this.props.disableAnimation ? 1 : normalizedTime * arrowSpacing;
139+
// Wrap time at the cycle duration to prevent overflow (performance.now() / 1000 gives seconds)
140+
const wrappedTime = (performance.now() / 1000) % cycleDuration;
141+
142+
// Calculate distance traveled in this cycle - this is the animation time value
143+
const animationTime = this.props.disableAnimation ? 1 : wrappedTime * adjustedSpeed;
135144
const animatedArrowProps: AnimatedArrowPathProps = {
136-
time: seamlessTime / zoomFactor,
145+
time: animationTime,
137146
arrowSpacing: arrowSpacing
138147
};
139-
(this.state.model as any)?.shaderInputs.setProps({ animatedArrowPath: animatedArrowProps });
148+
const model = this.state.model as { shaderInputs?: { setProps: (props: Record<string, unknown>) => void } };
149+
model?.shaderInputs?.setProps({ animatedArrowPath: animatedArrowProps });
140150
}
141151

142152
// See https://deck.gl/docs/developer-guide/custom-layers/picking for more information about picking colors
@@ -168,7 +178,8 @@ export default class AnimatedArrowPathExtension extends LayerExtension {
168178
float offset = vArrowPathOffset;
169179
float totalLength = vPathLength / vLengthRatio;
170180
float startDistance = vStartOffsetRatio * totalLength;
171-
float distanceSoFar = startDistance + vPathPosition.y - offset + percentFromCenter;
181+
// percentFromCenter * 2.0 makes the arrow twice as pointy.
182+
float distanceSoFar = startDistance + vPathPosition.y - offset + percentFromCenter * 2.0;
172183
float arrowIndex = mod(distanceSoFar, animatedArrowPath.arrowSpacing);
173184
float percentOfDistanceBetweenArrows = 1.0 - arrowIndex / animatedArrowPath.arrowSpacing;
174185
@@ -207,7 +218,7 @@ export default class AnimatedArrowPathExtension extends LayerExtension {
207218
arrowSpacing: 'f32'
208219
},
209220
inject
210-
} as ShaderModule<any>
221+
} as ShaderModule<AnimatedArrowPathProps>
211222
]
212223
};
213224
}

packages/evolution-frontend/src/components/admin/validations/InterviewList.tsx

Lines changed: 31 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,21 @@ import { _isBlank } from 'chaire-lib-common/lib/utils/LodashExtensions';
2020
import { InterviewStatusAttributesBase } from 'evolution-common/lib/services/questionnaire/types';
2121

2222
interface UsersTableProps extends WithTranslation {
23-
columns: any[];
23+
columns: Array<Record<string, unknown>>;
2424
data: InterviewStatusAttributesBase[];
25-
fetchData: ({ pageSize, pageIndex, filters }: any) => void;
25+
fetchData: (params: {
26+
pageSize: number;
27+
pageIndex: number;
28+
filters: unknown;
29+
sortBy: { id: string; desc?: boolean }[];
30+
}) => void;
2631
loading: boolean;
2732
pageCount: number;
2833
itemCount: number;
2934
initialSortBy: { id: string; desc?: boolean }[];
3035
interviewListChange: (show: boolean) => void;
3136
showInterviewList: boolean;
32-
validationInterview: any;
37+
validationInterview: InterviewStatusAttributesBase | null;
3338
}
3439

3540
// User react-table to handle a few table functionalities like paging and filtering
@@ -79,6 +84,28 @@ const InterviewList = (props: UsersTableProps) => {
7984
setSortBy(newSort);
8085
};
8186

87+
const getRowClassName = (
88+
row: { original: InterviewStatusAttributesBase },
89+
validationInterview: InterviewStatusAttributesBase | null
90+
): string => {
91+
if (validationInterview && row.original.uuid === validationInterview.uuid) {
92+
return '_active-background _blue';
93+
}
94+
if (row.original.is_validated && row.original.is_valid && row.original.is_completed) {
95+
return '_green _strong _active-background';
96+
}
97+
if (row.original.is_valid && row.original.is_completed) {
98+
return '_dark-green _strong';
99+
}
100+
if (row.original.is_valid && !row.original.is_completed) {
101+
return '_orange _strong';
102+
}
103+
if (row.original.is_valid === false) {
104+
return '_dark-red _strong';
105+
}
106+
return '';
107+
};
108+
82109
return props.loading ? (
83110
<div className="admin-widget-container">
84111
<LoadingPage />
@@ -168,24 +195,7 @@ const InterviewList = (props: UsersTableProps) => {
168195
return (
169196
<li
170197
title={row.original.uuid}
171-
className={`${
172-
row.original.is_valid === true && row.original.is_completed === true
173-
? '_dark-green _strong'
174-
: ''
175-
}
176-
${
177-
row.original.is_validated === true &&
178-
row.original.is_valid === true &&
179-
row.original.is_completed === true
180-
? '_green _strong _active-background'
181-
: ''
182-
}
183-
${
184-
row.original.is_valid === true && !row.original.is_completed === true
185-
? '_orange _strong'
186-
: ''
187-
}
188-
${row.original.is_valid === false ? '_dark-red _strong' : ''}`}
198+
className={getRowClassName(row, props.validationInterview)}
189199
{...row.getRowProps()}
190200
>
191201
{row.cells.map((cell, index) => {

0 commit comments

Comments
 (0)