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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## Unreleased

- Added `label_formatter` to `create_text_widget` to allow users to provide a function to customize the displayed label on top of text spans
- Added function-based `options` to let users dynamically choose options to display in the dropdown selectors (or radio)
- Minor fixes to the data widget explorer and quaero demo

## v1.0.0-beta.6 (2025-02-26)

New notebook-based tutorials : [each tutorial](https://percevalw.github.io/metanno/latest/tutorials/) can now be downloaded as a Jupyter notebook and run "at home".
Expand Down
38 changes: 6 additions & 32 deletions client/components/AnnotatedText/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,37 +23,6 @@ const isMobileDevice = (): boolean => {
return /Mobi|Android|iPhone|iPad|iPod|Windows Phone|IEMobile|Opera Mini/i.test(navigator.userAgent || "");
};

const toLuminance = (color: Color, y: number = 0.6) => {
// This is mostly used to adapt to light/dark mode

let [r, g, b, a] = [...color.rgb().color, color.alpha];
// let y = ((0.299 * r) + ( 0.587 * g) + ( 0.114 * b)) / 255;
let i = (0.596 * r + -0.275 * g + -0.321 * b) / 255;
let q = (0.212 * r + -0.523 * g + 0.311 * b) / 255;

r = (y + 0.956 * i + 0.621 * q) * 255;
g = (y + -0.272 * i + -0.647 * q) * 255;
b = (y + -1.105 * i + 1.702 * q) * 255;

// bounds-checking
if (r < 0) {
r = 0;
} else if (r > 255) {
r = 255;
}
if (g < 0) {
g = 0;
} else if (g > 255) {
g = 255;
}
if (b < 0) {
b = 0;
} else if (b > 255) {
b = 255;
}
return Color.rgb(r, g, b).alpha(a);
};

const processStyle = ({
color,
shape,
Expand Down Expand Up @@ -274,7 +243,7 @@ const Token = React.memo(
} as CSSProperties
}
>
{annotation.label.toUpperCase()}
{annotation.labelText}
</span>
);
}
Expand Down Expand Up @@ -485,6 +454,8 @@ const setOnMapping = (
* @param {Function} [props.onClickSpan] Callback for click events on spans.
* @param {Function} [props.onMouseEnterSpan] Callback for mouse enter events on spans.
* @param {Function} [props.onMouseLeaveSpan] Callback for mouse leave events on spans.
* @param {Function} [props.labelFormatter] Optional formatter `(span) => string`
* used to customize the rendered annotation label.
* @param {CSSProperties} [props.style] Custom styles for the component.
*/
export class AnnotatedText extends React.Component<TextData & TextMethods> {
Expand All @@ -496,6 +467,7 @@ export class AnnotatedText extends React.Component<TextData & TextMethods> {
beginKey: "begin",
endKey: "end",
labelKey: "label",
labelFormatter: undefined,
styleKey: "style",
highlightedKey: "highlighted",
selectedKey: "selected",
Expand All @@ -509,6 +481,7 @@ export class AnnotatedText extends React.Component<TextData & TextMethods> {
beginKey: string,
endKey: string,
labelKey: string,
labelFormatter: ((span: { [key: string]: any }) => string) | undefined,
styleKey: string,
highlightedKey: string,
selectedKey: string,
Expand Down Expand Up @@ -728,6 +701,7 @@ export class AnnotatedText extends React.Component<TextData & TextMethods> {
this.props.beginKey,
this.props.endKey,
this.props.labelKey,
this.props.labelFormatter,
this.props.styleKey,
this.props.highlightedKey,
this.props.selectedKey,
Expand Down
37 changes: 26 additions & 11 deletions client/components/AnnotatedText/tokenize.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ function styleTextChunks_(
});
};

spans.forEach(({ begin, end, label, style, ...rest }, span_i) => {
spans.forEach(({ begin, end, label, labelText, style, ...rest }, span_i) => {
style = style || label;
let newDepth = null,
newZIndex = null;
Expand Down Expand Up @@ -162,6 +162,7 @@ function styleTextChunks_(
openleft: text_chunks[text_chunk_i].begin !== begin,
openright: text_chunks[text_chunk_i].end !== end,
label: label,
labelText: labelText,
isFirstTokenOfSpan: text_chunks[text_chunk_i].begin === begin,
style: style,
zIndex: newZIndex,
Expand Down Expand Up @@ -232,6 +233,7 @@ export default function tokenize(
beginKey: string,
endKey: string,
labelKey: string,
labelFormatter: ((span: { [key: string]: any }) => string) | undefined,
styleKey: string,
highlightedKey: string,
selectedKey: string,
Expand All @@ -240,16 +242,29 @@ export default function tokenize(
lines: TokenData[][];
ids: any[];
} {
spans = spans.map((span, span_i) => ({
begin: span[beginKey],
end: span[endKey],
mouseSelected: span["mouseSelected"] || false,
label: span[labelKey] || null,
style: span[styleKey] || null,
highlighted: span[highlightedKey] || false,
selected: span[selectedKey] || false,
id: span[idKey] || span_i,
}));
spans = spans.map((span, span_i) => {
let rawLabel: unknown;
try {
rawLabel = labelFormatter ? labelFormatter(span) : span[labelKey];
} catch {
rawLabel = span[labelKey]?.toUpperCase();
}
const resolvedLabel =
rawLabel === undefined || rawLabel === null || rawLabel === ""
? null
: String(rawLabel);
return {
begin: span[beginKey],
end: span[endKey],
mouseSelected: span["mouseSelected"] || false,
label: span[labelKey] || null,
labelText: resolvedLabel,
style: span[styleKey] || null,
highlighted: span[highlightedKey] || false,
selected: span[selectedKey] || false,
id: span[idKey] || span_i,
};
});
// Sort the original spans to display by:
// 1. mouseSelected spans first
// 2. begin (left to right)
Expand Down
3 changes: 3 additions & 0 deletions client/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export type TextRange = {

export type TextAnnotation = {
mouseSelected: boolean;
labelText: string;
} & TextRange & Annotation;

export type TokenAnnotation = {
Expand All @@ -31,6 +32,7 @@ export type TokenAnnotation = {
openright: boolean;
isFirstTokenOfSpan: boolean;
mouseSelected: boolean;
labelText: string;
} & Annotation;

export type TokenData = {
Expand Down Expand Up @@ -60,6 +62,7 @@ export type TextData = {
beginKey: string;
endKey: string;
labelKey: string;
labelFormatter?: (span: { [key: string]: any }) => string;
styleKey: string;
highlightedKey: string;
selectedKey: string;
Expand Down
37 changes: 19 additions & 18 deletions docs/tutorials/run-quaero-explorer.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -248,17 +248,24 @@
"metadata": {},
"outputs": [],
"source": [
"def make_note_kind_options(note):\n",
" if \"EMEA\" in note[\"note_id\"]:\n",
" return [\"interesting\", \"very interesting\"]\n",
" else:\n",
" return []\n",
"\n",
"notes_table_handle: RefType[TableWidgetHandle] = use_ref()\n",
"notes_view = factory.create_table_widget(\n",
" store_key=\"notes\",\n",
" primary_key=\"note_id\",\n",
" fields=infer_fields(\n",
" data[\"notes\"],\n",
" visible_keys=[\"note_id\", \"seen\", \"note_text\", \"note_kind\"],\n",
" id_keys=[\"note_id\"],\n",
" editable_keys=[\"seen\", \"note_kind\"],\n",
" categorical_keys=[\"note_kind\"],\n",
" ),\n",
" # Instead of using infer_fields, we can also define the\n",
" # fields manually which can actually be simpler\n",
" fields=[ # type: ignore\n",
" {\"key\": \"note_id\", \"name\": \"note_id\", \"kind\": \"text\", \"filterable\": True},\n",
" {\"key\": \"note_text\", \"name\": \"note_text\", \"kind\": \"text\", \"editable\": False, \"filterable\": True}, # noqa: E501\n",
" {\"key\": \"note_kind\", \"name\": \"note_kind\", \"kind\": \"text\", \"editable\": True, \"options\": make_note_kind_options, \"filterable\": True}, # noqa: E501\n",
" {\"key\": \"seen\", \"name\": \"seen\", \"kind\": \"boolean\", \"editable\": True, \"filterable\": True}, # noqa: E501\n",
" ],\n",
" style={\"--min-notebook-height\": \"300px\"},\n",
" handle=notes_table_handle,\n",
")"
Expand All @@ -280,15 +287,12 @@
"outputs": [],
"source": [
"note_form_handle: RefType[FormWidgetHandle] = use_ref()\n",
"# fmt: off\n",
"note_form_view = factory.create_form_widget(\n",
" store_key=\"notes\",\n",
" primary_key=\"note_id\",\n",
" # Instead of using infer_fields, we can also define the\n",
" # fields manually which can actually be simpler\n",
" fields=[\n",
" fields=[ # type: ignore\n",
" {\"key\": \"note_id\", \"kind\": \"text\"},\n",
" {\"key\": \"note_kind\", \"kind\": \"radio\", \"editable\": True, \"options\": [\"interesting\", \"very interesting\"], \"filterable\": True},\n",
" {\"key\": \"note_kind\", \"kind\": \"radio\", \"editable\": True, \"options\": make_note_kind_options, \"filterable\": True}, # noqa: E501\n",
" {\"key\": \"seen\", \"kind\": \"boolean\", \"editable\": True},\n",
" ],\n",
" add_navigation_buttons=True,\n",
Expand Down Expand Up @@ -362,7 +366,6 @@
" spans_primary_key=\"id\",\n",
" labels=labels_config,\n",
" style={\"--min-notebook-height\": \"300px\"},\n",
" handle=note_text_handle,\n",
")"
]
},
Expand Down Expand Up @@ -411,12 +414,9 @@
"from pret_markdown import Markdown\n",
"from pret_simple_dock import Layout, Panel\n",
"\n",
"layout = div(\n",
"layout = Stack(\n",
" Layout(\n",
" Panel(\n",
" div(Markdown(\"A markdown description of the dataset/task\"), style={\"margin\": \"10px\"}),\n",
" key=\"Description\",\n",
" ),\n",
" div(Markdown(\"A markdown description of the dataset/task\"), style={\"margin\": \"10px\"}),\n",
" Panel(notes_view, key=\"Notes\"),\n",
" Panel(entities_view, key=\"Entities\"),\n",
" Panel(note_text_view, key=\"Note Text\", header=note_header),\n",
Expand Down Expand Up @@ -452,6 +452,7 @@
" \"Entities\",\n",
" ],\n",
" ),\n",
" factory.create_connection_status_bar(),\n",
" style={\n",
" \"background\": \"var(--joy-palette-background-level2, #f0f0f0)\",\n",
" \"width\": \"100%\",\n",
Expand Down
45 changes: 25 additions & 20 deletions examples/quaero.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
- one table view for entities, showing the entity ID, note ID, text, label, concept, etc
- one text viewer for the note text, with the entities highlighted and editable.
- one view for the note metadata displayed as a form (a fake field "note_kind" was added
for demo purposes)
for demo purposes, and only applied to EMEA notes)

Visit the [tutorial](https://percevalw.github.io/metanno/latest/tutorials/run-quaero-explorer) to
learn how to build such an app yourself.
Expand Down Expand Up @@ -103,7 +103,9 @@ def build_data():
{
"note_id": str(doc._.note_id),
"note_text": doc.text,
"note_kind": "interesting" if idx % 2 == 0 else "very interesting",
"note_kind": ("interesting" if idx % 2 == 0 else "very interesting")
if "EMEA" in doc._.note_id
else "",
"seen": False,
"entities": [
{
Expand Down Expand Up @@ -149,44 +151,47 @@ def app(save_path=None, deduplicate=False):
# --8<-- [start:render-views]
# Create handles to control the widgets imperatively

def make_note_kind_options(note):
if "EMEA" in note["note_id"]:
return ["interesting", "very interesting"]
else:
return []

# View the documents as a table
# fmt: off
notes_table_handle: RefType[TableWidgetHandle] = use_ref()
notes_view = factory.create_table_widget(
store_key="notes",
primary_key="note_id",
fields=infer_fields(
data["notes"],
visible_keys=["note_id", "seen", "note_text", "note_kind"],
id_keys=["note_id"],
editable_keys=["seen", "note_kind"],
categorical_keys=["note_kind"],
),
# Instead of using infer_fields, we can also define the
# fields manually which can actually be simpler
fields=[ # type: ignore
{"key": "note_id", "name": "note_id", "kind": "text", "filterable": True},
{"key": "note_text", "name": "note_text", "kind": "text", "editable": False, "filterable": True}, # noqa: E501
{"key": "note_kind", "name": "note_kind", "kind": "text", "editable": True, "options": make_note_kind_options, "filterable": True}, # noqa: E501
{"key": "seen", "name": "seen", "kind": "boolean", "editable": True, "filterable": True}, # noqa: E501
],
style={"--min-notebook-height": "300px"},
handle=notes_table_handle,
)
# fmt: on

# Show the selected note as a form
# fmt: off
note_form_handle: RefType[FormWidgetHandle] = use_ref()
note_form_view = factory.create_form_widget(
store_key="notes",
primary_key="note_id",
# Instead of using infer_fields, we can also define the
# fields manually which can actually be simpler
fields=[
fields=[ # type: ignore
{"key": "note_id", "kind": "text"},
{
"key": "note_kind",
"kind": "radio",
"editable": True,
"options": ["interesting", "very interesting"],
"filterable": True,
},
{"key": "note_kind", "kind": "radio", "editable": True, "options": make_note_kind_options, "filterable": True}, # noqa: E501
{"key": "seen", "kind": "boolean", "editable": True},
],
add_navigation_buttons=True,
style={"--min-notebook-height": "300px", "margin": "10px"},
handle=note_form_handle,
)
# fmt: on

# View the entities as a table
ents_table_handle: RefType[TableWidgetHandle] = use_ref()
Expand Down Expand Up @@ -326,4 +331,4 @@ def app(save_path=None, deduplicate=False):
port = args.port
save_path = args.save_path
host = args.host
run(app(save_path=True, deduplicate=True)[0], port=port, host=host)
run(app(save_path=save_path, deduplicate=True)[0], port=port, host=host)
Loading
Loading