Skip to content
Closed
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
Binary file added .github/screenshots/rank-icons-family.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/screenshots/rank-icons-species.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/screenshots/rank-icons-tree.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
124 changes: 124 additions & 0 deletions frontend/src/components/common/RankIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import * as React from "react";

interface RankIconProps {
rank: string | undefined;
size?: number;
title?: string;
/** Optional override; otherwise inherits currentColor from surrounding text. */
color?: string;
}

const FILL = "currentColor";

const PENTAGON: ReadonlyArray<readonly [number, number]> = [
[24, 12],
[37.31, 21.67],
[32.23, 37.33],
[15.77, 37.33],
[10.69, 21.67],
];
const SQUARE: ReadonlyArray<readonly [number, number]> = [
[14, 14],
[34, 14],
[14, 34],
[34, 34],
];
const TRIANGLE: ReadonlyArray<readonly [number, number]> = [
[24, 12],
[36.12, 33],
[11.88, 33],
];

function filledDots(pts: ReadonlyArray<readonly [number, number]>, r: number) {
return pts.map(([cx, cy], i) => <circle key={i} cx={cx} cy={cy} r={r} fill={FILL} />);
}
function hollowDots(pts: ReadonlyArray<readonly [number, number]>, r: number, sw: number) {
return pts.map(([cx, cy], i) => (
<circle key={i} cx={cx} cy={cy} r={r} fill="none" stroke={FILL} strokeWidth={sw} />
));
}
function filledBars(ys: number[]) {
return ys.map((y, i) => <rect key={i} x={8} y={y} width={32} height={4} fill={FILL} />);
}
function splitBars(ys: number[]) {
return ys.flatMap((y, i) => [
<rect key={`l${i}`} x={8} y={y} width={13} height={4} fill={FILL} />,
<rect key={`r${i}`} x={27} y={y} width={13} height={4} fill={FILL} />,
]);
}

function glyphFor(rank: string): React.ReactNode | null {
switch (rank) {
case "domain":
return filledBars([14, 22, 30]);
case "subdomain":
return splitBars([14, 22, 30]);
case "kingdom":
return filledBars([18, 26]);
case "subkingdom":
return splitBars([18, 26]);
case "phylum":
case "division":
return filledBars([22]);
case "subphylum":
case "subdivision":
return splitBars([22]);
case "class":
return filledDots(PENTAGON, 4);
case "subclass":
return hollowDots(PENTAGON, 4, 1.5);
case "order":
return filledDots(SQUARE, 4);
case "suborder":
return hollowDots(SQUARE, 4, 1.5);
case "family":
return filledDots(TRIANGLE, 4);
case "subfamily":
return hollowDots(TRIANGLE, 4, 1.5);
case "genus":
return [
<circle key="l" cx={16} cy={24} r={5} fill={FILL} />,
<circle key="r" cx={32} cy={24} r={5} fill={FILL} />,
];
case "subgenus":
return [
<circle key="l" cx={16} cy={24} r={5} fill="none" stroke={FILL} strokeWidth={2} />,
<circle key="r" cx={32} cy={24} r={5} fill="none" stroke={FILL} strokeWidth={2} />,
];
case "species":
return <circle cx={24} cy={24} r={6} fill={FILL} />;
case "subspecies":
case "variety":
case "form":
case "forma":
return <circle cx={24} cy={24} r={6} fill="none" stroke={FILL} strokeWidth={2} />;
default:
return null;
}
}

/**
* A glyph for a Linnaean rank. Phylum and above use stacked bars; below phylum
* uses 1–5 dots arranged on the implied polygon. Sub-ranks are the hollow or
* gap-split version of the parent. Returns null for ranks without a glyph
* (tribe, super-/infra- ranks, etc.).
*/
export function RankIcon({ rank, size = 16, title, color }: RankIconProps) {
if (!rank) return null;
const glyph = glyphFor(rank.trim().toLowerCase());
if (!glyph) return null;
return (
<svg
width={size}
height={size}
viewBox="0 0 48 48"
role={title ? "img" : undefined}
aria-label={title}
aria-hidden={title ? undefined : true}
style={{ flexShrink: 0, display: "inline-block", color }}
>
{title ? <title>{title}</title> : null}
{glyph}
</svg>
);
}
62 changes: 42 additions & 20 deletions frontend/src/components/taxon/TaxonDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import type { TaxonDetail as TaxonDetailType, Occurrence } from "../../services/
import { slugToName } from "../../lib/taxonSlug";
import { ConservationStatus } from "../common/ConservationStatus";
import { TaxonLink, shouldItalicizeTaxonName } from "../common/TaxonLink";
import { RankIcon } from "../common/RankIcon";
import { usePageTitle } from "../../hooks/usePageTitle";
import { useWikidataThumbnails } from "../../hooks/useWikidataThumbnails";
import { WikiTaxonThumbnail } from "../common/WikiTaxonThumbnail";
Expand Down Expand Up @@ -210,14 +211,17 @@ export function TaxonDetail() {
<IconButton onClick={handleBack} sx={{ mr: 1 }}>
<ArrowBackIcon />
</IconButton>
<Typography
variant="subtitle1"
sx={{
fontWeight: 500,
}}
>
{taxon.rank.charAt(0).toUpperCase() + taxon.rank.slice(1)}
</Typography>
<Box sx={{ display: "flex", alignItems: "center", gap: 0.75 }}>
<RankIcon rank={taxon.rank} size={18} title={taxon.rank} />
<Typography
variant="subtitle1"
sx={{
fontWeight: 500,
}}
>
{taxon.rank.charAt(0).toUpperCase() + taxon.rank.slice(1)}
</Typography>
</Box>
</Box>

{/* Hero Image */}
Expand Down Expand Up @@ -270,6 +274,9 @@ export function TaxonDetail() {
/
</Typography>
)}
<Box component="span" sx={{ color: "text.secondary", display: "inline-flex" }}>
<RankIcon rank={ancestor.rank} size={12} title={ancestor.rank} />
</Box>
<TaxonLink name={ancestor.name} kingdom={taxon.kingdom} rank={ancestor.rank} />
</Box>
))}
Expand Down Expand Up @@ -404,14 +411,19 @@ export function TaxonDetail() {
rank={ancestor.rank}
variant="text"
/>
<Typography
variant="caption"
<Box
sx={{
display: "inline-flex",
alignItems: "center",
gap: 0.5,
color: "text.disabled",
}}
>
{ancestor.rank}
</Typography>
<RankIcon rank={ancestor.rank} size={12} title={ancestor.rank} />
<Typography variant="caption" sx={{ color: "inherit" }}>
{ancestor.rank}
</Typography>
</Box>
</Box>
</ListItem>
))}
Expand Down Expand Up @@ -441,14 +453,19 @@ export function TaxonDetail() {
>
{taxon.scientificName}
</Typography>
<Typography
variant="caption"
<Box
sx={{
display: "inline-flex",
alignItems: "center",
gap: 0.5,
color: "text.disabled",
}}
>
{taxon.rank}
</Typography>
<RankIcon rank={taxon.rank} size={12} title={taxon.rank} />
<Typography variant="caption" sx={{ color: "inherit" }}>
{taxon.rank}
</Typography>
</Box>
</Box>
</ListItem>
{/* Children */}
Expand All @@ -473,14 +490,19 @@ export function TaxonDetail() {
rank={child.rank}
variant="text"
/>
<Typography
variant="caption"
<Box
sx={{
display: "inline-flex",
alignItems: "center",
gap: 0.5,
color: "text.disabled",
}}
>
{child.rank}
</Typography>
<RankIcon rank={child.rank} size={12} title={child.rank} />
<Typography variant="caption" sx={{ color: "inherit" }}>
{child.rank}
</Typography>
</Box>
</Box>
</ListItem>
))}
Expand Down
21 changes: 12 additions & 9 deletions frontend/src/components/taxon/TaxonDetailPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import type { TaxonDetail as TaxonDetailType, Occurrence } from "../../services/types";
import { ConservationStatus } from "../common/ConservationStatus";
import { shouldItalicizeTaxonName } from "../common/TaxonLink";
import { RankIcon } from "../common/RankIcon";
import { WikiCommonsGallery } from "../common/WikiCommonsGallery";
import { FeedItem } from "../feed/FeedItem";

Expand Down Expand Up @@ -67,15 +68,17 @@ export function TaxonDetailPanel({
<IconButton onClick={onBack} sx={{ mr: 1 }}>
<ArrowBackIcon />
</IconButton>
<Typography
variant="subtitle1"
sx={{
fontWeight: 500,
flex: 1,
}}
>
{taxon.rank.charAt(0).toUpperCase() + taxon.rank.slice(1)}
</Typography>
<Box sx={{ display: "flex", alignItems: "center", gap: 0.75, flex: 1 }}>
<RankIcon rank={taxon.rank} size={18} title={taxon.rank} />
<Typography
variant="subtitle1"
sx={{
fontWeight: 500,
}}
>
{taxon.rank.charAt(0).toUpperCase() + taxon.rank.slice(1)}
</Typography>
</Box>
{onToggleTree && (
<IconButton onClick={onToggleTree} sx={{ display: { xs: "inline-flex", md: "none" } }}>
<AccountTreeIcon />
Expand Down
14 changes: 10 additions & 4 deletions frontend/src/components/taxon/TaxonTreePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { SimpleTreeView } from "@mui/x-tree-view/SimpleTreeView";
import { TreeItem } from "@mui/x-tree-view/TreeItem";
import type { TaxonTreeItem } from "./TaxonExplorer";
import { shouldItalicizeTaxonName } from "../common/TaxonLink";
import { RankIcon } from "../common/RankIcon";

interface TaxonTreePanelProps {
items: TaxonTreeItem[];
Expand Down Expand Up @@ -34,16 +35,21 @@ function renderTreeItems(items: TaxonTreeItem[], selectedId: string, loadingNode
>
{item.label}
</Typography>
<Typography
variant="caption"
<Box
component="span"
sx={{
display: "inline-flex",
alignItems: "center",
gap: 0.5,
color: "text.disabled",
flexShrink: 0,
}}
>
{item.rank}
</Typography>
<RankIcon rank={item.rank} size={12} title={item.rank} />
<Typography variant="caption" component="span" sx={{ color: "inherit" }}>
{item.rank}
</Typography>
</Box>
{loadingNodeId === item.id && <CircularProgress size={14} />}
</Box>
}
Expand Down
Loading