diff --git a/package.json b/package.json
index 76bd1e0..fa0a652 100644
--- a/package.json
+++ b/package.json
@@ -32,6 +32,7 @@
"@babel/runtime": "^7.8.4",
"@react-three/fiber": "^8.10.0",
"@reduxjs/toolkit": "^1.9.1",
+ "lodash": "^4.17.23",
"match-sorter": "^6.3.1",
"ol": "^9.0.0",
"react": "^18.2.0",
@@ -47,8 +48,8 @@
}
},
"resolutions": {
- "@react-three/fiber/its-fine": "~1.1.4",
- "diff": "^8.0.3"
+ "lodash": "4.17.23",
+ "@react-three/fiber/its-fine": "~1.1.4"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}
diff --git a/src/containers/App/Prefetch.js b/src/containers/App/Prefetch.js
index 6f4f25d..8d7cdeb 100644
--- a/src/containers/App/Prefetch.js
+++ b/src/containers/App/Prefetch.js
@@ -9,10 +9,8 @@ const Prefetch = ({children}) => {
const prefetchSynonyms = usePrefetchSynonyms();
prefetchTaxa();
- console.log('prefetching taxa');
prefetchSynonyms();
- console.log('prefetching synonyms');
return children;
};
diff --git a/src/containers/ImageLabeling/ImageLabelingPage.js b/src/containers/ImageLabeling/ImageLabelingPage.js
index a2aca45..be5d12d 100644
--- a/src/containers/ImageLabeling/ImageLabelingPage.js
+++ b/src/containers/ImageLabeling/ImageLabelingPage.js
@@ -135,7 +135,7 @@ const ImageLabelingPage = ({ location, history }) => {
limit: 1000,
fields: ['slug', 'renditions', 'related_taxon', 'taxon', 'attributes', 'file', 'priority'],
};
- if (selectedTaxon && selectedTaxon !== '__no_taxon__') {
+ if (selectedTaxon && selectedTaxon !== 'unknown') {
p.taxon = selectedTaxon;
}
return p;
@@ -208,7 +208,7 @@ const ImageLabelingPage = ({ location, history }) => {
name = taxonObj;
}
} else {
- slug = '__no_taxon__';
+ slug = 'unknown';
name = 'Unknown taxon';
}
@@ -218,8 +218,8 @@ const ImageLabelingPage = ({ location, history }) => {
});
return Array.from(map.values()).sort((a, b) => {
- if (a.slug === '__no_taxon__') return 1;
- if (b.slug === '__no_taxon__') return -1;
+ if (a.slug === 'unknown') return 1;
+ if (b.slug === 'unknown') return -1;
return String(a.name).localeCompare(String(b.name));
});
}, [filteredAllImages, hasActiveFilters]);
@@ -231,8 +231,8 @@ const ImageLabelingPage = ({ location, history }) => {
}
return [...(summary?.taxa || [])].sort((a, b) => {
- if (a.slug === '__no_taxon__') return 1;
- if (b.slug === '__no_taxon__') return -1;
+ if (a.slug === 'unknown') return 1;
+ if (b.slug === 'unknown') return -1;
return String(a.name).localeCompare(String(b.name));
});
}, [hasActiveFilters, filteredTaxaMap, summary]);
@@ -245,7 +245,7 @@ const ImageLabelingPage = ({ location, history }) => {
const filteredImages = React.useMemo(() => {
let result = images;
- if (selectedTaxon === '__no_taxon__') {
+ if (selectedTaxon === 'unknown') {
result = result.filter((img) => !img.relatedTaxon && !img.taxon);
}
@@ -316,7 +316,7 @@ const ImageLabelingPage = ({ location, history }) => {
name = taxonObj;
}
} else {
- slug = '__no_taxon__';
+ slug = 'unknown';
name = 'Unknown taxon';
}
}
@@ -350,8 +350,8 @@ const ImageLabelingPage = ({ location, history }) => {
});
return Array.from(taxonImages.values()).sort((a, b) => {
- if (a.taxonSlug === '__no_taxon__') return 1;
- if (b.taxonSlug === '__no_taxon__') return -1;
+ if (a.taxonSlug === 'unknown') return 1;
+ if (b.taxonSlug === 'unknown') return -1;
return String(a.taxonName).localeCompare(String(b.taxonName));
});
}, [isLandingPage, hasActiveFilters, filteredAllImages, landingImages, taxaList]);
@@ -405,7 +405,7 @@ const ImageLabelingPage = ({ location, history }) => {
// Use image data if available, otherwise fall back to store data for taxa without images
const relatedTaxon = relatedTaxonFromImages || (
- !isLandingPage && selectedTaxon && selectedTaxon !== '__no_taxon__'
+ !isLandingPage && selectedTaxon && selectedTaxon !== 'unknown'
? selectedTaxonFromStore
: null
);
@@ -455,7 +455,7 @@ const ImageLabelingPage = ({ location, history }) => {
.
- ) : selectedTaxon === '__no_taxon__' ? (
+ ) : selectedTaxon === 'unknown' ? (
Unknown taxon
diff --git a/src/containers/ImageLabeling/ImageLabelingPage.scss b/src/containers/ImageLabeling/ImageLabelingPage.scss
index ebcb92a..4d156dd 100644
--- a/src/containers/ImageLabeling/ImageLabelingPage.scss
+++ b/src/containers/ImageLabeling/ImageLabelingPage.scss
@@ -33,10 +33,34 @@
color: #666;
}
- .taxonomy-all-taxa {
+ .taxonomy-expand-collapse {
+ display: flex;
+ gap: 0.5rem;
margin-bottom: 0.5rem;
}
+ .taxonomy-expand-collapse-button {
+ flex: 1;
+ padding: 0.25rem 0.5rem;
+ font-size: 0.75rem;
+ color: #666;
+ background: transparent;
+ border: 1px solid #ddd;
+ border-radius: 0.25rem;
+ cursor: pointer;
+
+ &:hover {
+ background: rgba(0, 0, 0, 0.05);
+ color: #333;
+ }
+ }
+
+ .taxonomy-all-taxa {
+ margin-bottom: 1rem;
+ padding-bottom: 1rem;
+ border-bottom: 1px solid #e0e0e0;
+ }
+
.taxonomy-all-taxa-button,
.taxonomy-unknown-button {
display: block;
@@ -78,6 +102,111 @@
margin-left: 0;
}
}
+
+ // Plankton groups tree styles
+ .plankton-groups-tree {
+ margin-top: 0.5rem;
+ }
+
+ .plankton-groups-list {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ }
+
+ .plankton-group-item {
+ margin-bottom: 0.25rem;
+ }
+
+ .plankton-group-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.5rem 0.75rem;
+ background: #f5f5f5;
+ border-radius: 0.25rem;
+ }
+
+ .plankton-group-name {
+ font-weight: 500;
+ font-size: 0.875rem;
+ color: #333;
+ }
+
+ .plankton-group-toggle,
+ .plankton-taxon-toggle {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 1.5rem;
+ height: 1.5rem;
+ padding: 0;
+ background: transparent;
+ border: none;
+ cursor: pointer;
+ color: #666;
+
+ &:hover {
+ color: #000;
+ }
+
+ svg {
+ width: 0.75rem;
+ height: 0.75rem;
+ }
+ }
+
+ .plankton-taxa-list {
+ list-style: none;
+ padding: 0;
+ margin: 0.25rem 0 0.5rem 1rem;
+ }
+
+ .plankton-taxon-item {
+ margin-bottom: 0.125rem;
+ }
+
+ .plankton-taxon-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.375rem 0.5rem;
+ border-radius: 0.25rem;
+
+ &:hover {
+ background: rgba(0, 0, 0, 0.03);
+ }
+ }
+
+ .plankton-taxon-link {
+ flex: 1;
+ font-size: 0.8125rem;
+ color: #444;
+ text-decoration: none;
+
+ &:hover {
+ color: #0066cc;
+ }
+
+ &.is-selected {
+ font-weight: 500;
+ color: #000;
+ }
+ }
+
+ .plankton-titles-list {
+ list-style: none;
+ padding: 0;
+ margin: 0.125rem 0 0.375rem 1.5rem;
+ }
+
+ .plankton-title-item {
+ padding: 0.25rem 0.5rem;
+ font-size: 0.75rem;
+ color: #666;
+ border-left: 2px solid #e0e0e0;
+ margin-bottom: 0.125rem;
+ }
}
// Filters toggle button (for taxonomy sidebar on mobile)
diff --git a/src/containers/ImageLabeling/ImageLabelingTaxonomy.js b/src/containers/ImageLabeling/ImageLabelingTaxonomy.js
index 9797f6d..11ecd7a 100644
--- a/src/containers/ImageLabeling/ImageLabelingTaxonomy.js
+++ b/src/containers/ImageLabeling/ImageLabelingTaxonomy.js
@@ -1,10 +1,9 @@
import React, { useEffect, useState, useMemo } from 'react';
import PropTypes from 'prop-types';
-import { Link, useHistory } from 'react-router-dom';
-import { useSelector } from 'react-redux';
+import { Link } from 'react-router-dom';
-import Tree from 'Components/Taxonomy/Tree';
-import { useGetAllTaxaQuery, selectById } from 'Slices/taxa';
+import { useGetImageLabelingGroupedByPlanktonQuery } from 'Slices/labeling';
+import { PlusIcon, DashIcon } from 'Components/Icons';
const propTypes = {
selectedTaxon: PropTypes.string,
@@ -17,6 +16,17 @@ const propTypes = {
const ImageLabelingTaxonomy = ({ selectedTaxon, onTaxonSelect, imageLabelingTaxa }) => {
const [navigationIsExpanded, setNavigationIsExpanded] = useState(false);
+ const [expandedGroups, setExpandedGroups] = useState(null); // null = not initialized yet
+ const [expandedTaxa, setExpandedTaxa] = useState(new Set());
+
+ const { data: groupedData = [], isLoading } = useGetImageLabelingGroupedByPlanktonQuery();
+
+ // Initialize expanded groups to all groups when data first loads
+ useEffect(() => {
+ if (groupedData.length > 0 && expandedGroups === null) {
+ setExpandedGroups(new Set(groupedData.map(g => g.group_name)));
+ }
+ }, [groupedData, expandedGroups]);
useEffect(() => {
if (navigationIsExpanded) {
@@ -29,148 +39,77 @@ const ImageLabelingTaxonomy = ({ selectedTaxon, onTaxonSelect, imageLabelingTaxa
};
}, [navigationIsExpanded]);
- const query = useGetAllTaxaQuery();
-
- const selectedTaxonData = useSelector(
- state => selectById(state, selectedTaxon)
- );
-
- const getTaxonKey = ({ slug }) => slug;
-
const handleClickToggleTaxonomy = () => {
setNavigationIsExpanded(!navigationIsExpanded);
};
- // Build a map of taxa slugs to their image counts (stable reference)
- const imageLabelingCounts = useMemo(() => {
- if (!imageLabelingTaxa) return new Map();
- const map = new Map();
- imageLabelingTaxa.forEach(t => {
- if (t.slug && t.slug !== '__no_taxon__') {
- map.set(t.slug, t.count || 0);
+ const toggleGroup = (groupName) => {
+ setExpandedGroups(prev => {
+ // If prev is null, initialize with all groups expanded except the one being toggled
+ if (prev === null) {
+ const allGroups = new Set(groupedData.map(g => g.group_name));
+ allGroups.delete(groupName);
+ return allGroups;
}
- });
- return map;
- }, [imageLabelingTaxa]);
-
- // Ranks that should only show if they have images
- const conditionalRanks = new Set(['Kingdom', 'Phylum', 'Order', 'Family', 'Genus']);
-
- // Filter to only show taxa with images and their ancestors
- const filteredEntities = useMemo(() => {
- if (!query.data?.entities || imageLabelingCounts.size === 0) {
- return null;
- }
-
- const entities = query.data.entities;
- const includedSlugs = new Set();
-
- // For each taxon with images, include it and ALL its ancestors
- imageLabelingCounts.forEach((count, slug) => {
- const taxon = entities[slug];
- if (taxon) {
- includedSlugs.add(slug);
-
- if (taxon.classification) {
- taxon.classification.forEach(ancestor => {
- const ancestorSlug = typeof ancestor === 'string' ? ancestor : ancestor.slug;
- if (ancestorSlug) {
- includedSlugs.add(ancestorSlug);
- }
- });
- }
+ const next = new Set(prev);
+ if (next.has(groupName)) {
+ next.delete(groupName);
+ } else {
+ next.add(groupName);
}
+ return next;
});
+ };
- // Helper to determine effective rank
- const getEffectiveRank = (slug) => {
- const taxon = entities[slug];
- if (!taxon) return null;
- const imageCount = imageLabelingCounts.get(slug) || 0;
- const isConditionalRank = conditionalRanks.has(taxon.rank);
- const hasImages = imageCount > 0;
- return (isConditionalRank && !hasImages) ? '_SkippedRank' : taxon.rank;
- };
-
- // Build filtered entities
- const filtered = {};
- includedSlugs.forEach(slug => {
- const taxon = entities[slug];
- if (taxon) {
- const imageCount = imageLabelingCounts.get(slug) || 0;
-
- // Filter children to only those in includedSlugs
- // AND update their rank in the child object for reduceChildren to read
- const filteredChildren = (taxon.children || [])
- .filter(child => {
- const childSlug = typeof child === 'string' ? child : child.slug;
- return includedSlugs.has(childSlug);
- })
- .map(child => {
- const childSlug = typeof child === 'string' ? child : child.slug;
- const childObj = typeof child === 'object' ? child : { slug: childSlug };
- return {
- ...childObj,
- slug: childSlug,
- rank: getEffectiveRank(childSlug),
- };
- });
-
- filtered[slug] = {
- ...taxon,
- rank: getEffectiveRank(slug),
- children: filteredChildren,
- scientificName: imageCount > 0
- ? `${taxon.scientificName} (${imageCount})`
- : taxon.scientificName,
- };
+ const toggleTaxon = (taxonSlug) => {
+ setExpandedTaxa(prev => {
+ const next = new Set(prev);
+ if (next.has(taxonSlug)) {
+ next.delete(taxonSlug);
+ } else {
+ next.add(taxonSlug);
}
+ return next;
});
+ };
- return filtered;
- }, [query.data?.entities, imageLabelingCounts]);
+ const expandAll = () => {
+ setExpandedGroups(new Set(groupedData.map(g => g.group_name)));
+ // Also expand all taxa
+ const allTaxaSlugs = groupedData.flatMap(g => g.taxa.map(t => t.taxon_slug));
+ setExpandedTaxa(new Set(allTaxaSlugs));
+ };
- // Determine which ranks to display
- const displayRanks = useMemo(() => {
- const baseRanks = ['Domain', 'Class', 'Genus', 'Species', 'Subspecies', 'Variety', 'Form', 'Forma'];
-
- if (!query.data?.entities || imageLabelingCounts.size === 0) {
- return baseRanks;
- }
+ const collapseAll = () => {
+ setExpandedGroups(new Set());
+ setExpandedTaxa(new Set());
+ };
- const entities = query.data.entities;
- const ranksToAdd = new Set();
-
- // Check which conditional ranks have taxa with images
- imageLabelingCounts.forEach((count, slug) => {
- if (count > 0) {
- const taxon = entities[slug];
- if (taxon?.rank && conditionalRanks.has(taxon.rank)) {
- ranksToAdd.add(taxon.rank);
- }
+ const resetToDefault = () => {
+ // Default: groups expanded, taxa collapsed
+ setExpandedGroups(new Set(groupedData.map(g => g.group_name)));
+ setExpandedTaxa(new Set());
+ };
+
+ // Build a map of taxa slugs to their image counts from the summary data
+ const imageLabelingCounts = useMemo(() => {
+ if (!imageLabelingTaxa) return new Map();
+ const map = new Map();
+ imageLabelingTaxa.forEach(t => {
+ if (t.slug && t.slug !== 'unknown') {
+ map.set(t.slug, t.count || 0);
}
});
+ return map;
+ }, [imageLabelingTaxa]);
- return [...baseRanks, ...ranksToAdd];
- }, [query.data?.entities, imageLabelingCounts]);
-
- // Always keep tree fully expanded
- const initialPath = useMemo(() => {
- if (filteredEntities) {
- return Object.keys(filteredEntities);
- }
- return [];
- }, [filteredEntities]);
-
- const selectedKey = selectedTaxonData ? getTaxonKey(selectedTaxonData) : null;
-
- const hasTaxa = filteredEntities && Object.keys(filteredEntities).length > 0;
- const hasUnknownTaxon = imageLabelingTaxa?.some(t => t.slug === '__no_taxon__');
- const unknownTaxonCount = imageLabelingTaxa?.find(t => t.slug === '__no_taxon__')?.count || 0;
+ const hasUnknownTaxon = imageLabelingTaxa?.some(t => t.slug === 'unknown');
+ const unknownTaxonCount = imageLabelingTaxa?.find(t => t.slug === 'unknown')?.count || 0;
- const totalImageCount = useMemo(() => {
+ // Count number of taxa (excluding unknown)
+ const totalTaxaCount = useMemo(() => {
if (!imageLabelingTaxa) return 0;
- return imageLabelingTaxa.reduce((sum, t) => sum + (t.count || 0), 0);
+ return imageLabelingTaxa.filter(t => t.slug !== 'unknown').length;
}, [imageLabelingTaxa]);
return (
@@ -193,9 +132,35 @@ const ImageLabelingTaxonomy = ({ selectedTaxon, onTaxonSelect, imageLabelingTaxa
Image Labeling Guide
-
+
Taxonomy
-
+
+ {!isLoading && groupedData.length > 0 && (
+
+
+
+
+
+ )}
+
- {hasTaxa && (
-
-
({
- to: `/image-labeling/?taxon=${slug}`,
+ {isLoading && (
+ Loading...
+ )}
+
+ {!isLoading && groupedData.length > 0 && (
+
+
+ {groupedData.map((group) => {
+ const isGroupExpanded = expandedGroups?.has(group.group_name) ?? true;
+ const groupTaxonCount = group.taxa.reduce(
+ (sum, t) => sum + (imageLabelingCounts.get(t.taxon_slug) || 0),
+ 0
+ );
+
+ return (
+ -
+
+
+ {group.group_name} ({groupTaxonCount})
+
+
+
+
+ {isGroupExpanded && (
+
+ {group.taxa.map((taxon) => {
+ const isTaxonExpanded = expandedTaxa.has(taxon.taxon_slug);
+ const taxonCount = imageLabelingCounts.get(taxon.taxon_slug) || 0;
+ const hasTitles = taxon.titles && taxon.titles.length > 0;
+
+ return (
+ -
+
+
{
+ onTaxonSelect(taxon.taxon_slug);
+ setNavigationIsExpanded(false);
+ }}
+ >
+
{taxon.taxon_name} ({taxonCount})
+
+ {hasTitles && (
+
+ )}
+
+
+ {isTaxonExpanded && hasTitles && (
+
+ {taxon.titles.map((title) => (
+ -
+ {title}
+
+ ))}
+
+ )}
+
+ );
+ })}
+
+ )}
+
+ );
})}
- />
+
)}
@@ -228,10 +260,10 @@ const ImageLabelingTaxonomy = ({ selectedTaxon, onTaxonSelect, imageLabelingTaxa
@@ -245,4 +277,4 @@ const ImageLabelingTaxonomy = ({ selectedTaxon, onTaxonSelect, imageLabelingTaxa
ImageLabelingTaxonomy.propTypes = propTypes;
-export default ImageLabelingTaxonomy;
\ No newline at end of file
+export default ImageLabelingTaxonomy;
diff --git a/src/slices/labeling.js b/src/slices/labeling.js
index 0bfbe8f..1edacc8 100644
--- a/src/slices/labeling.js
+++ b/src/slices/labeling.js
@@ -31,12 +31,19 @@ export const extendedApiSlice = baseApi.injectEndpoints({
getImageLabelingSummary: builder.query({
query: () => 'media/image_labeling/summary',
}),
+
+ // Get taxa grouped by plankton groups with class names (titles)
+ getImageLabelingGroupedByPlankton: builder.query({
+ query: () => 'media/image_labeling/grouped_by_plankton',
+ transformResponse: responseData => responseData.groups || [],
+ }),
}),
overrideExisting: false,
});
-export const {
+export const {
useGetImageLabelingImagesQuery,
useGetImageLabelingSummaryQuery,
useGetImageLabelingFirstPerTaxonQuery,
+ useGetImageLabelingGroupedByPlanktonQuery,
} = extendedApiSlice;
\ No newline at end of file
diff --git a/yarn.lock b/yarn.lock
index 98f87dd..d345016 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2213,10 +2213,10 @@ detect-node@^2.0.4:
resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.1.0.tgz#c9c70775a49c3d03bc2c06d9a73be550f978f8b1"
integrity sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==
-diff@^5.0.0, diff@^8.0.3:
- version "8.0.3"
- resolved "https://registry.yarnpkg.com/diff/-/diff-8.0.3.tgz#c7da3d9e0e8c283bb548681f8d7174653720c2d5"
- integrity sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==
+diff@^5.0.0:
+ version "5.2.2"
+ resolved "https://registry.yarnpkg.com/diff/-/diff-5.2.2.tgz#0a4742797281d09cfa699b79ea32d27723623bad"
+ integrity sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==
dns-packet@^5.2.2:
version "5.6.1"
@@ -3055,10 +3055,10 @@ lodash.debounce@^4.0.8:
resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"
integrity sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==
-lodash@^4.17.20, lodash@^4.17.21:
- version "4.17.21"
- resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
- integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
+lodash@4.17.23, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.23:
+ version "4.17.23"
+ resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.23.tgz#f113b0378386103be4f6893388c73d0bde7f2c5a"
+ integrity sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==
loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.1, loose-envify@^1.4.0:
version "1.4.0"