diff --git a/lit_nlp/client/elements/lit_input_field.ts b/lit_nlp/client/elements/lit_input_field.ts new file mode 100644 index 00000000..2e1cf367 --- /dev/null +++ b/lit_nlp/client/elements/lit_input_field.ts @@ -0,0 +1,124 @@ +/** + * @license + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @fileoverview A custom element that maps a LitType and (optionally) its + * corresponding value to an HTML representation for editing or display. + */ + +// tslint:disable:no-new-decorators + +import {html} from 'lit'; +import {customElement, property} from 'lit/decorators'; +import {AnnotationCluster, EdgeLabel, ScoredTextCandidate, ScoredTextCandidates, SpanLabel} from '../lib/dtypes'; +import {ReactiveElement} from '../lib/elements'; +import {BooleanLitType, EdgeLabels, Embeddings, GeneratedTextCandidates, LitType, MultiSegmentAnnotations, Scalar, SpanLabels, StringLitType} from '../lib/lit_types'; +import {formatAnnotationCluster, formatBoolean, formatEdgeLabel, formatEmbeddings, formatNumber, formatScoredTextCandidate, formatScoredTextCandidates, formatScoredTextCandidatesList, formatSpanLabel} from '../lib/type_rendering'; +import {chunkWords} from '../lib/utils'; + +function isGeneratedTextCandidate(input: unknown): boolean { + return Array.isArray(input) && input.length === 2 && + typeof input[0] === 'string' && + (input[1] == null || typeof input[1] === 'number'); +} + +/** + * An element for mapping LitType fields to the correct HTML input type. + */ +@customElement('lit-input-field') +export class LitInputField extends ReactiveElement { + @property({type: String}) name = ''; + @property({type: Object}) type = new LitType(); + @property({type: Boolean}) readonly = false; + @property() value: unknown; + @property({type: Boolean}) limitWords = false; + + override render() { + return this.readonly ? this.renderReadonly() : this.renderEditable(); + } + + /** + * Converts the input value to a readonly representaiton for display pruposes. + */ + private renderReadonly(): string|number { + if (Array.isArray(this.value)) { + if (isGeneratedTextCandidate(this.value)) { + return formatScoredTextCandidate(this.value as ScoredTextCandidate); + } + + if (Array.isArray(this.value[0]) && + isGeneratedTextCandidate(this.value[0])) { + return formatScoredTextCandidates(this.value as ScoredTextCandidates); + } + + const strings = this.value.map((item) => { + if (typeof item === 'number') {return formatNumber(item);} + if (this.limitWords) {return chunkWords(item);} + return `${item}`; + }); + return strings.join(', '); + } else if (this.type instanceof BooleanLitType) { + return formatBoolean(this.value as boolean); + } else if (this.type instanceof EdgeLabels) { + const formattedTags = (this.value as EdgeLabel[]).map(formatEdgeLabel); + return formattedTags.join(', '); + } else if (this.type instanceof Embeddings) { + return formatEmbeddings(this.value); + } else if (this.type instanceof GeneratedTextCandidates) { + return formatScoredTextCandidatesList( + this.value as ScoredTextCandidates[]); + } else if (this.type instanceof MultiSegmentAnnotations) { + const formattedTags = + (this.value as AnnotationCluster[]).map(formatAnnotationCluster); + return formattedTags.join(', '); + } else if (this.type instanceof Scalar || typeof this.value === 'number') { + return formatNumber(this.value as number); + } else if (this.type instanceof SpanLabels) { + const formattedTags = + (this.value as SpanLabel[]).map(s => formatSpanLabel(s)) as string[]; + return formattedTags.join(', '); + } else if (this.type instanceof StringLitType || + typeof this.value === 'string') { + return this.limitWords ? + chunkWords(this.value as string) : this.value as string; + } else { + return ''; + } + } + + /** + * Renders the appropriate HTML elements to enable user input to control the + * value of the field with which this element is associated. + */ + private renderEditable() { + if (this.type instanceof BooleanLitType) { + const change = (e: Event) => { + this.value = !!(e.target as HTMLInputElement).value; + dispatchEvent(new Event('change')); + }; + return html``; + } else { + return html`Unsupported type '${this.type.name}' cannot be rnedered.`; + } + } +} + +declare global { + interface HTMLElementTagNameMap { + 'lit-input-field': LitInputField; + } +} diff --git a/lit_nlp/client/elements/lit_input_field_test.ts b/lit_nlp/client/elements/lit_input_field_test.ts new file mode 100644 index 00000000..b11cf3a1 --- /dev/null +++ b/lit_nlp/client/elements/lit_input_field_test.ts @@ -0,0 +1,6 @@ +import 'jasmine'; + +describe('lit input field test', () => { + it('computes a thing when asked', () => { + }); +}); diff --git a/lit_nlp/client/lib/type_rendering.ts b/lit_nlp/client/lib/type_rendering.ts new file mode 100644 index 00000000..f5372539 --- /dev/null +++ b/lit_nlp/client/lib/type_rendering.ts @@ -0,0 +1,143 @@ +/** + * @license + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * @fileoverview Functions for converting LitType classes to base HTML elements + * for editing or display. + * + * As a naming convention, exported functions that support editing via HTML + * inputs, selects, etc, start with "render" (because they used Lit.dev's HTML + * templates to render elements) and functions that are intended for readonly + * display start with "format" (because they typically convert the value to a + * string-like representation). + */ + +import {html} from 'lit'; +import {StyleInfo, styleMap} from 'lit/directives/style-map'; +import {AnnotationCluster, EdgeLabel, ScoredTextCandidate, ScoredTextCandidates, SpanLabel} from './dtypes'; + +type EventHandler = (e: Event) => void; + +/** Formats an AnnotationCluster for display. */ +export function formatAnnotationCluster(ac: AnnotationCluster): string { + return `${ac.label}${ac.score != null ? ` (${ac.score})` : ''}`; +} + +/** + * Formats an AnnotationCluster[] (e.g., from MultiSegmentAnnotations) for + * display as a
of
s. + */ +export function formatAnnotationClusters(clusters: AnnotationCluster[]) { + return html`
${clusters.map(c => + html`
+
${formatAnnotationCluster(c)}
+
    ${c.spans.map(s => html`
  • ${formatSpanLabel(s, true)}
  • `)}
+
`) + }
`; +} + +/** Formats a boolean value for display. */ +export function formatBoolean(val: boolean): string { + return val ? '✔' : ' '; +} + +/** Formats an EdgeLabel for display. */ +export function formatEdgeLabel(e: EdgeLabel): string { + function formatSpan (s: [number, number]) {return `[${s[0]}, ${s[1]})`;} + const span1Text = formatSpan(e.span1); + const span2Text = e.span2 ? ` ← ${formatSpan(e.span2)}` : ''; + // Add non-breaking control chars to keep this on one line + // TODO(lit-dev): get it to stop breaking between ) and :; \u2060 doesn't work + return `${span1Text}${span2Text}\u2060: ${e.label}`.replace( + /\ /g, '\u00a0' /*   */); +} + +/** Formats Embeddings for display. */ +export function formatEmbeddings(val: unknown): string { + return Array.isArray(val) ? `[${val.length}]` : ''; +} + +/** Formats a number to a fixed (3 decimal) value for display. */ +export function formatNumber (item: number): number { + return Number.isInteger(item) ? item : Number(item.toFixed(3)); +} + +/** Formats a ScoredTextCandidate for display. */ +export function formatScoredTextCandidate([t, s]: ScoredTextCandidate): string { + return `${t}${typeof s === 'number' ? ` (${formatNumber(s)})` : ''}`; +} + +/** Formats a ScoredTextCandidates for display. */ +export function formatScoredTextCandidates(stc: ScoredTextCandidates): string { + return stc.map(formatScoredTextCandidate).join('\n\n'); +} + +/** Formats a list of ScoredTextCandidates for display. */ +export function formatScoredTextCandidatesList( + list: ScoredTextCandidates[]): string { + return list.map(formatScoredTextCandidates).join('\n\n'); +} + +/** Formats a SpanLabel for display. + * + * By default, it formats the SpanLabel as a string. If monospace is true, then + * the function returns an HTML Template wrapping the default string in a + * element. + */ +export function formatSpanLabel(s: SpanLabel, monospace = false) { + let formatted = `[${s.start}, ${s.end})`; + if (s.align) { + formatted = `${s.align} ${formatted}`; + } + if (s.label) { + // TODO(lit-dev): Stop from breaking between ) and :, \u2060 doesn't work + formatted = `${formatted}\u2060: ${s.label}`; + } + + formatted = formatted.replace(/\ /g, '\u00a0' /*   */); + return monospace ? + html`
${formatted}
` : formatted; +} + +/** Formats a SpanLabel[] as a
of
s. */ +export function formatSpanLabels(labels: SpanLabel[]) { + return html`
${ + labels.map(s =>formatSpanLabel(s, true)) + }
`; +} + +/** Renders a + ${vocab.map(cat => + html``)} + + `; +} + +/** Renders a `; +} + +/** Renders an for short-form textual input. */ +export function renderTextInputShort(input: EventHandler, value = '') { + return html``; +} diff --git a/lit_nlp/client/lib/type_rendering_test.ts b/lit_nlp/client/lib/type_rendering_test.ts new file mode 100644 index 00000000..af748ce7 --- /dev/null +++ b/lit_nlp/client/lib/type_rendering_test.ts @@ -0,0 +1,320 @@ +import 'jasmine'; + +import {render} from 'lit'; +import {AnnotationCluster, EdgeLabel, ScoredTextCandidates, SpanLabel} from './dtypes'; +import {formatAnnotationCluster, formatAnnotationClusters, formatBoolean, formatEdgeLabel, formatEmbeddings, formatNumber, formatScoredTextCandidate, formatScoredTextCandidates, formatScoredTextCandidatesList, formatSpanLabel, formatSpanLabels, renderCategoricalInput, renderTextInputLong, renderTextInputShort} from './type_rendering'; + +describe('Tests for readonly data formatting', () => { + describe('AnnotationCluster formatting test', () => { + it('formats an AnnotationCluster with a label and score', () => { + const label = 'some_label'; + const score = 0.123; + const formatted = formatAnnotationCluster({label, score, spans: []}); + expect(formatted).toBe(`${label} (${score})`); + }); + + it('formats an AnnotationCluster with only a label', () => { + const label = 'some_label'; + const formatted = formatAnnotationCluster({label, spans: []}); + expect(formatted).toBe(label); + }); + + it('formats a list of AnnotationClusters with labels and scores', () => { + const clusters: AnnotationCluster[] = [ + {label: 'label_1', score: 0.123, spans: []}, + {label: 'label_2', score: 1.234, spans: []}, + {label: 'label_3', score: 2.345, spans: []}, + ]; + const formatted = formatAnnotationClusters(clusters); + render(formatted, document.body); + const multiSegAnnoDiv = + document.body.querySelector('div.multi-segment-annotation'); + + expect(multiSegAnnoDiv).toBeInstanceOf(HTMLDivElement); + Array.from(multiSegAnnoDiv!.children).forEach((child, i) => { + expect(child).toBeInstanceOf(HTMLDivElement); + expect(child).toHaveClass('annotation-cluster'); + const [div, list] = child.children; + const {label, score} = clusters[i]; + expect(div).toBeInstanceOf(HTMLDivElement); + expect(div.textContent).toBe(`${label} (${score})`); + expect(list).toBeInstanceOf(HTMLUListElement); + }); + }); + + it('formats a list of AnnotationClusters with only labels', () => { + const clusters: AnnotationCluster[] = [ + {label: 'label_1', spans: []}, + {label: 'label_2', spans: []}, + {label: 'label_3', spans: []}, + ]; + const formatted = formatAnnotationClusters(clusters); + render(formatted, document.body); + const multiSegAnnoDiv = + document.body.querySelector('div.multi-segment-annotation'); + + expect(multiSegAnnoDiv).toBeInstanceOf(HTMLDivElement); + Array.from(multiSegAnnoDiv!.children).forEach((child, i) => { + expect(child).toBeInstanceOf(HTMLDivElement); + expect(child).toHaveClass('annotation-cluster'); + const [div, list] = child.children; + expect(div).toBeInstanceOf(HTMLDivElement); + expect(div.textContent).toBe(clusters[i].label); + expect(list).toBeInstanceOf(HTMLUListElement); + }); + }); + + }); + + describe('Boolean formatting test', () => { + [ + {value: false, expected: ' '}, + {value: true, expected: '✔'} + ].forEach(({value, expected}) => { + it(`should format '${value}' as '${expected}'`, () => { + expect(formatBoolean(value)).toBe(expected); + }); + }); + }); + + describe('EdgeLabel formatting test', () => { + it('formats an EdgeLabel with 1 span', () => { + const label: EdgeLabel = { + span1: [0, 10], + label: 'correct' + }; + const formatted = formatEdgeLabel(label); + const expected = `[0,\u00a010)\u2060:\u00a0correct`; + expect(formatted).toBe(expected); + }); + + it('formats an EdgeLabel with 2 spans', () => { + const label: EdgeLabel = { + span1: [0, 10], + span2: [10, 20], + label: 'correct' + }; + const formatted = formatEdgeLabel(label); + const expected = + `[0,\u00a010)\u00a0←\u00a0[10,\u00a020)\u2060:\u00a0correct`; + expect(formatted).toBe(expected); + }); + }); + + describe('Embeddings formatting test', () => { + [ + {value: undefined, expected: ''}, + {value: null, expected: ''}, + {value: [], expected: '[0]'}, + {value: [1], expected: '[1]'}, + {value: [1, 2, 3, 4], expected: '[4]'}, + ].forEach(({value, expected}) => { + const name = value != null ? `[${value}]` : value; + it(`should format ${name} as '${expected}'`, () => { + expect(formatEmbeddings(value)).toBe(expected); + }); + }); + }); + + describe('Number formatting test', () => { + [ + {value: 1, expected: 1}, + {value: 1.0, expected: 1}, + {value: 1.001, expected: 1.001}, + {value: 1.0001, expected: 1}, + {value: 1.0009, expected: 1.001}, + ].forEach(({value, expected}) => { + it(`should format '${value}' as '${expected}'`, () => { + expect(formatNumber(value)).toBe(expected); + }); + }); + }); + + describe('ScoredTextCandidate formatting test', () => { + it('formats a ScoredTextCandidate with text', () => { + const text = 'some test text'; + expect(formatScoredTextCandidate([text, null])).toBe(text); + }); + + it('formats a ScoredTextCandidate with text and score', () => { + const text = 'some test text'; + const score = 0.123; + expect(formatScoredTextCandidate([text, score])).toBe(`${text} (${score})`); + }); + + it('formats ScoredTextCandidates with text', () => { + const candidates: ScoredTextCandidates = [ + ['text_1', null], + ['text_2', null], + ['text_3', null], + ]; + const expected = 'text_1\n\ntext_2\n\ntext_3'; + expect(formatScoredTextCandidates(candidates)).toBe(expected); + }); + + it('formats ScoredTextCandidates with text and scores', () => { + const candidates: ScoredTextCandidates = [ + ['text_1', 0.123], + ['text_2', 1.234], + ['text_3', 2.345], + ]; + const expected = 'text_1 (0.123)\n\ntext_2 (1.234)\n\ntext_3 (2.345)'; + expect(formatScoredTextCandidates(candidates)).toBe(expected); + }); + + it('formats a ScoredTextCandidates[] with text', () => { + const candidates: ScoredTextCandidates[] = [ + [['text_1', null], ['text_2', null], ['text_3', null]], + [['text_1', null], ['text_2', null], ['text_3', null]], + ]; + const expected = 'text_1\n\ntext_2\n\ntext_3'; + const formatted = formatScoredTextCandidatesList(candidates); + expect(formatted).toBe(`${expected}\n\n${expected}`); + }); + + it('formats a ScoredTextCandidates[] with text and scores', () => { + const candidates: ScoredTextCandidates[] = [ + [['text_1', 0.123], ['text_2', 1.234], ['text_3', 2.345]], + [['text_1', 0.123], ['text_2', 1.234], ['text_3', 2.345]], + ]; + const expected = 'text_1 (0.123)\n\ntext_2 (1.234)\n\ntext_3 (2.345)'; + const formatted = formatScoredTextCandidatesList(candidates); + expect(formatted).toBe(`${expected}\n\n${expected}`); + }); + }); + + describe('SpanLabel formatting test', () => { + const labels: SpanLabel[] = [ + {start: 0, end: 1}, + {start: 0, end: 1, align: 'field_name'}, + {start: 0, end: 1, label: 'label'}, + {start: 0, end: 1, align: 'field_name', label: 'label'}, + ]; + + const expected: string[] = [ + '[0,\u00a01)', + 'field_name\u00a0[0,\u00a01)', + '[0,\u00a01)\u2060:\u00a0label', + 'field_name\u00a0[0,\u00a01)\u2060:\u00a0label', + ]; + + labels.forEach((label, i) => { + it('converts a SpanLabel as a string by default', () => { + const formatted = formatSpanLabel(label); + expect(formatted).toBe(expected[i]); + }); + + it('converts a SpanLabel to a
when monospace=true', () => { + const formatted = formatSpanLabel(label, true); + render(formatted, document.body); + const div = document.body.querySelector('div.monospace-label'); + expect(div).toBeInstanceOf(HTMLDivElement); + expect(div!.textContent).toBe(expected[i]); + }); + }); + + it('converts a SpanLabel[] to a
of
s', () => { + const formatted = formatSpanLabels(labels); + render(formatted, document.body); + const div = document.body.querySelector('div.span-labels'); + + expect(div).toBeInstanceOf(HTMLDivElement); + Array.from(div!.children).forEach((child, i) => { + expect(child).toBeInstanceOf(HTMLDivElement); + expect(child).toHaveClass('monospace-label'); + expect(child.textContent).toBe(expected[i]); + }); + }); + }); +}); + +describe('Test for editable data rendering', () => { + function handler(e: Event) {} + + describe('Categroical input rendering test', () => { + const vocab = ['contradiction', 'entailment', 'neutral']; + + it('renders a with the correct