Skip to content
Open
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
2 changes: 1 addition & 1 deletion web/libs/datamanager/src/sdk/lsf-sdk.js
Original file line number Diff line number Diff line change
Expand Up @@ -760,7 +760,7 @@ export class LSFWrapper {

saveDraft = async (target = null) => {
const selected = target || this.lsf?.annotationStore?.selected;
const hasChanges = this.needsDraftSave(selected);
const hasChanges = selected ? this.needsDraftSave(selected) : false;

if (selected?.isDraftSaving) {
await when(() => !selected.isDraftSaving);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ export const AnnotationButton = observer(
if (type === "prediction") {
annotationStore.selectPrediction(id);
} else {
annotationStore.selectAnnotation(id);
annotationStore.selectAnnotation(id, { exitViewAll: true });
}
}
}, [entity]);
Expand Down
4 changes: 3 additions & 1 deletion web/libs/editor/src/components/App/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,9 @@ class App extends Component {
}

_renderUI(root, as) {
if (as.viewingAll) return this.renderAllAnnotations();
if (as.viewingAll && getRoot(as).hasInterface("annotations:view-all")) {
return this.renderAllAnnotations();
}

return (
<div
Expand Down
166 changes: 166 additions & 0 deletions web/libs/editor/src/components/TaskSummary/Aggregation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { cnm } from "@humansignal/ui";
import type { RawResult } from "../../stores/types";
import { Chip } from "./Chip";
import type { AnnotationSummary, ControlTag } from "./types";
import { getLabelCounts } from "./utils";

const resultValue = (result: RawResult) => {
if (result.type === "textarea") {
return result.value.text;
}
return result.value[result.type];
};

export const AggregationRow = ({
control,
annotations,
countEmpty,
isExpanded,
}: { control: ControlTag; annotations: AnnotationSummary[]; countEmpty: boolean; isExpanded: boolean }) => {
const allResults = annotations.flatMap((ann) => ann.results.filter((r) => r.from_name === control.name));

if (!allResults.length) {
return <span className="text-neutral-content-subtler text-xs italic">No data</span>;
}

const totalAnnotations = countEmpty ? annotations.length : allResults.length;

// Handle labels-type controls
if (control.type.endsWith("labels")) {
const allLabels = allResults.flatMap((r) => resultValue(r)).flat();
const labelCounts = getLabelCounts(allLabels, control.label_attrs);

// Sort by count descending
const sortedLabels = Object.entries(labelCounts)
.filter(([_, data]) => data.count > 0)
.sort(([, a], [, b]) => b.count - a.count);

return (
<div className={cnm("text-ellipsis", !isExpanded && "line-clamp-2")}>
{sortedLabels.map(([label, data]) => {
return (
<Chip
key={label}
prefix={data.count}
colors={{
background: data.background,
border: data.border,
color: data.color || data.border,
}}
className="mr-tighter mb-tighter"
thickBorder
>
{label}
</Chip>
);
})}
</div>
);
}

// Handle pairwise; they are similar to choices but produce only `left` or `right` values
if (control.type === "pairwise") {
const allPairwise = allResults.flatMap((r) => resultValue(r)).flat();
const pairwiseCounts: Record<string, number> = {};

allPairwise.forEach((pairwise) => {
pairwiseCounts[pairwise] = (pairwiseCounts[pairwise] || 0) + 1;
});
const sortedPairwise = Object.entries(pairwiseCounts).sort(([, a], [, b]) => b - a);

return (
<div className={cnm("text-ellipsis", !isExpanded && "line-clamp-2")}>
{sortedPairwise.map(([pairwise, count]) => {
return (
<Chip key={pairwise} prefix={count} className="mr-tighter mb-tighter">
{pairwise}
</Chip>
);
})}
</div>
);
}

// Handle choices
if (control.type === "choices") {
const allChoices = allResults.flatMap((r) => resultValue(r)).flat();
const choiceCounts: Record<string, number> = {};

allChoices.forEach((choice) => {
choiceCounts[choice] = (choiceCounts[choice] || 0) + 1;
});

const sortedChoices = Object.entries(choiceCounts).sort(([, a], [, b]) => b - a);

return (
<div className={cnm("text-ellipsis", !isExpanded && "line-clamp-2")}>
{sortedChoices.map(([choice, count]) => {
return (
<Chip
key={choice}
prefix={`${((count / totalAnnotations) * 100).toFixed(1)}%`}
colors={{ background: control.label_attrs[choice]?.background }}
className="mr-tighter mb-tighter"
>
{choice}
</Chip>
);
})}
</div>
);
}

// Handle taxonomy
if (control.type === "taxonomy") {
const values = allResults.flatMap((r) => resultValue(r)?.map((r: string[]) => r.at(-1)));
const pathCounts: Record<string, number> = {};

values.filter(Boolean).forEach((path: string | string[]) => {
const pathStr = Array.isArray(path) ? path.join(" / ") : path;
pathCounts[pathStr] = (pathCounts[pathStr] || 0) + 1;
});

const sortedPaths = Object.entries(pathCounts).sort(([, a], [, b]) => b - a);

return (
<div className={cnm("text-ellipsis", !isExpanded && "line-clamp-2")}>
{sortedPaths.map(([path, count]) => {
return (
<Chip key={path} prefix={`${((count / totalAnnotations) * 100).toFixed(1)}%`} className="mr-tighter mb-tighter">
{path}
</Chip>
);
})}
</div>
);
}

// Handle rating
if (control.type === "rating") {
const ratings = allResults.map((r) => resultValue(r)).filter(Boolean);
if (!ratings.length) return <span className="text-neutral-content-subtler text-xs italic">No ratings</span>;

const avgRating = ratings.reduce((sum, val) => sum + val, 0) / (countEmpty ? totalAnnotations : ratings.length);
return (
<span className="text-sm font-medium text-neutral-content-subtle">
Avg: <span className="font-bold">{avgRating.toFixed(1)}</span> <span className="text-yellow-500">★</span>
</span>
);
}

// Handle number
if (control.type === "number") {
const numbers = allResults.map((r) => resultValue(r)).filter((v) => v !== null && v !== undefined);
if (!numbers.length) return <span className="text-neutral-content-subtler text-xs italic">No data</span>;

const avg = numbers.reduce((sum, val) => sum + Number(val), 0) / (countEmpty ? totalAnnotations : numbers.length);
return (
<span className="text-sm font-medium text-neutral-content-subtle">
Avg: <span className="font-bold">{avg.toFixed(1)}</span>
</span>
);
}

// Default: show N/A
return <span className="text-sm font-medium text-neutral-content-subtler">N/A</span>;
};
72 changes: 72 additions & 0 deletions web/libs/editor/src/components/TaskSummary/Chip.tsx
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we not have a similar Badge component in the UI lib which could be used here?

Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import type { PropsWithChildren, CSSProperties } from "react";
import { cnm } from "@humansignal/ui";

interface ChipProps extends PropsWithChildren {
/**
* Optional prefix content (e.g., count, percentage) that appears before the main content with a divider
*/
prefix?: React.ReactNode;

/**
* Optional color configuration from label_attrs
*/
colors?: {
background?: string;
border?: string;
color?: string;
};

/**
* Additional inline styles to apply
*/
style?: CSSProperties;

/**
* Whether to show a thick left border (typically for labels)
*/
thickBorder?: boolean;

/**
* Additional CSS classes
*/
className?: string;
}

/**
* Unified chip component for displaying labels, badges, and tags throughout the Task Summary.
* Supports various styling options including colors, borders, and prefixes for counts/percentages.
*/
export const Chip = ({ children, prefix, colors, style, thickBorder = false, className }: ChipProps) => {
const combinedStyles: CSSProperties = {
...style,
...(colors?.background && { background: colors.background }),
...(colors?.border && { borderColor: colors.border }),
...(colors?.color && { color: colors.color }),
...(thickBorder && colors?.border && { borderLeft: `3px solid ${colors.border}` }),
};
const isPercentage = typeof prefix === "string" && prefix.endsWith("%");

if (!children) return null;

return (
<span
className={cnm(
"inline-flex items-center whitespace-nowrap rounded-4 px-2 py-0.5",
"text-xs border",
!colors?.background && "bg-neutral-surface-subtle",
!colors?.border && "border-neutral-border",
!colors?.color && "text-neutral-content",
className,
)}
style={combinedStyles}
>
{prefix && (
<>
<span className="font-semibold">{prefix}</span>
{isPercentage ? <span className="opacity-50 mx-tighter">|</span> : <span className="opacity-50 mx-tightest">×</span>}
</>
)}
{children}
</span>
);
};
4 changes: 2 additions & 2 deletions web/libs/editor/src/components/TaskSummary/DataSummary.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useMemo } from "react";
import { flexRender, getCoreRowModel, useReactTable, createColumnHelper } from "@tanstack/react-table";
import { cnm } from "@humansignal/ui";
import { SummaryBadge } from "./SummaryBadge";
import { Chip } from "./Chip";
import { ResizeHandler } from "./ResizeHandler";
import type { ObjectTypes } from "./types";

Expand All @@ -18,7 +18,7 @@ export const DataSummary = ({ data_types }: { data_types: ObjectTypes }) => {
id: field,
header: () => (
<>
{field} <SummaryBadge>{type}</SummaryBadge>
{field} <Chip>{type}</Chip>
</>
),
cell: ({ getValue }) => {
Expand Down
Loading
Loading