@things-factory/ai-assistant 10.1.90 → 10.1.97
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/components/assistant-chat.ts +31 -3
- package/client/components/assistant-dock.ts +9 -1
- package/client/components/entry-review.ts +56 -0
- package/client/components/workspace-view.ts +428 -0
- package/client/pages/assistant-workspace-page.ts +237 -0
- package/client/route.ts +9 -0
- package/client/utils/entry-proposal.ts +20 -5
- package/client/utils/workspace.ts +89 -0
- package/dist-client/components/assistant-chat.d.ts +2 -0
- package/dist-client/components/assistant-chat.js +32 -3
- package/dist-client/components/assistant-chat.js.map +1 -1
- package/dist-client/components/assistant-dock.d.ts +1 -0
- package/dist-client/components/assistant-dock.js +9 -1
- package/dist-client/components/assistant-dock.js.map +1 -1
- package/dist-client/components/entry-review.d.ts +5 -0
- package/dist-client/components/entry-review.js +55 -0
- package/dist-client/components/entry-review.js.map +1 -1
- package/dist-client/components/workspace-view.d.ts +19 -0
- package/dist-client/components/workspace-view.js +416 -0
- package/dist-client/components/workspace-view.js.map +1 -0
- package/dist-client/pages/assistant-workspace-page.d.ts +27 -0
- package/dist-client/pages/assistant-workspace-page.js +263 -0
- package/dist-client/pages/assistant-workspace-page.js.map +1 -0
- package/dist-client/route.d.ts +1 -0
- package/dist-client/route.js +10 -0
- package/dist-client/route.js.map +1 -0
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-client/utils/entry-proposal.d.ts +5 -0
- package/dist-client/utils/entry-proposal.js +16 -5
- package/dist-client/utils/entry-proposal.js.map +1 -1
- package/dist-client/utils/workspace.d.ts +40 -0
- package/dist-client/utils/workspace.js +68 -0
- package/dist-client/utils/workspace.js.map +1 -0
- package/dist-server/service/assistant-mode/resolve.d.ts +2 -2
- package/dist-server/service/assistant-mode/resolve.js +5 -3
- package/dist-server/service/assistant-mode/resolve.js.map +1 -1
- package/dist-server/service/data-entry/entry-apply.d.ts +3 -3
- package/dist-server/service/data-entry/entry-apply.js +55 -2
- package/dist-server/service/data-entry/entry-apply.js.map +1 -1
- package/dist-server/service/data-entry/entry-resolver.js +3 -7
- package/dist-server/service/data-entry/entry-resolver.js.map +1 -1
- package/dist-server/service/data-entry/entry-target.d.ts +51 -0
- package/dist-server/service/data-entry/entry-target.js +77 -34
- package/dist-server/service/data-entry/entry-target.js.map +1 -1
- package/dist-server/service/data-entry/entry-tools.d.ts +7 -1
- package/dist-server/service/data-entry/entry-tools.js +73 -3
- package/dist-server/service/data-entry/entry-tools.js.map +1 -1
- package/dist-server/service/data-entry/entry-update.d.ts +99 -0
- package/dist-server/service/data-entry/entry-update.js +196 -0
- package/dist-server/service/data-entry/entry-update.js.map +1 -0
- package/dist-server/service/index.d.ts +2 -0
- package/dist-server/service/index.js +3 -0
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/workspace/workspace-blocks.d.ts +24 -0
- package/dist-server/service/workspace/workspace-blocks.js +118 -0
- package/dist-server/service/workspace/workspace-blocks.js.map +1 -0
- package/dist-server/service/workspace/workspace-tools.d.ts +24 -0
- package/dist-server/service/workspace/workspace-tools.js +65 -0
- package/dist-server/service/workspace/workspace-tools.js.map +1 -0
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/server/service/assistant-mode/resolve.test.ts +2 -1
- package/server/service/assistant-mode/resolve.ts +5 -3
- package/server/service/data-entry/data-entry-update.test.ts +192 -0
- package/server/service/data-entry/data-entry.test.ts +2 -2
- package/server/service/data-entry/entry-apply.ts +63 -6
- package/server/service/data-entry/entry-resolver.ts +4 -9
- package/server/service/data-entry/entry-target.ts +97 -31
- package/server/service/data-entry/entry-tools.ts +77 -3
- package/server/service/data-entry/entry-update.ts +209 -0
- package/server/service/index.ts +3 -0
- package/server/service/workspace/workspace-blocks.ts +127 -0
- package/server/service/workspace/workspace-tools.ts +66 -0
- package/server/service/workspace/workspace.test.ts +58 -0
- package/test/entry-proposal.test.ts +15 -2
- package/test/workspace.test.ts +54 -0
- package/things-factory.config.js +6 -1
- package/translations/en.json +36 -1
- package/translations/ja.json +36 -1
- package/translations/ko.json +36 -1
- package/translations/ms.json +36 -1
- package/translations/zh.json +36 -1
|
@@ -64,7 +64,8 @@ import { isDifferentConversation, resolveConversation, type AssistantMode, type
|
|
|
64
64
|
import { parseProposalOutcomeLine, parseProposalRequestLine, proposalCardState, type ParsedProposalOutcome } from './proposal-outcome-line.js'
|
|
65
65
|
import { availableTriggers, type TriggerChip } from './chat-triggers.js'
|
|
66
66
|
import './entry-review.js'
|
|
67
|
-
import { entryReadyCount, isEntryProposal, latestEntryKeys } from '../utils/entry-proposal.js'
|
|
67
|
+
import { entryGroupOf, entryReadyCount, isChangeProposal, isEntryProposal, latestEntryKeys } from '../utils/entry-proposal.js'
|
|
68
|
+
import { isWorkspaceProposal, versionOfCard, workspaceAddress } from '../utils/workspace.js'
|
|
68
69
|
|
|
69
70
|
interface ChatLine {
|
|
70
71
|
role: 'user' | 'assistant' | 'system'
|
|
@@ -3953,9 +3954,11 @@ export class OxAssistantChat extends LitElement {
|
|
|
3953
3954
|
<div class="proposals">
|
|
3954
3955
|
${items.map(p => {
|
|
3955
3956
|
const key = this.proposalKey(p)
|
|
3957
|
+
/* A workspace card opens the page; it runs nothing (design/ai/workspace.md §4). */
|
|
3958
|
+
if (isWorkspaceProposal(p)) return this.renderWorkspaceCard(p, key)
|
|
3956
3959
|
const card = proposalCardState(key, { outcomes, requested, sentHere: this.sentProposals.has(key) })
|
|
3957
3960
|
const entry = isEntryProposal(p)
|
|
3958
|
-
const replaced = entry && latestEntry?.get(p
|
|
3961
|
+
const replaced = entry && latestEntry?.get(entryGroupOf(p)) !== key && card.state === 'ready'
|
|
3959
3962
|
const ready = entry ? entryReadyCount(p) : 0
|
|
3960
3963
|
const sent = card.state !== 'ready' || replaced || (entry && ready === 0)
|
|
3961
3964
|
/*
|
|
@@ -3997,7 +4000,9 @@ export class OxAssistantChat extends LitElement {
|
|
|
3997
4000
|
? this.proposalStateText(key)
|
|
3998
4001
|
: entry
|
|
3999
4002
|
? ready
|
|
4000
|
-
?
|
|
4003
|
+
? isChangeProposal(p)
|
|
4004
|
+
? i18next.t('ai-assistant.entry.change', { defaultValue: 'change {count} records', count: ready })
|
|
4005
|
+
: i18next.t('ai-assistant.entry.enter', { defaultValue: 'enter {count} rows', count: ready })
|
|
4001
4006
|
: i18next.t('ai-assistant.entry.nothing-to-enter', { defaultValue: 'nothing to enter' })
|
|
4002
4007
|
: i18next.t('ai-assistant.button.run-proposal', { defaultValue: 'run' })}
|
|
4003
4008
|
</button>
|
|
@@ -4035,6 +4040,29 @@ export class OxAssistantChat extends LitElement {
|
|
|
4035
4040
|
`
|
|
4036
4041
|
}
|
|
4037
4042
|
|
|
4043
|
+
/** The AI 작업판 card - what it holds, which version, and the way to open it. The person opens it; the model does not. */
|
|
4044
|
+
private renderWorkspaceCard(p: any, key: string) {
|
|
4045
|
+
const { version, of } = versionOfCard(this.lines.map(one => one.proposals ?? []), p.workspace.name, key)
|
|
4046
|
+
const contents = Object.entries(p.contents ?? {})
|
|
4047
|
+
.map(([kind, count]) => String(i18next.t(`ai-assistant.workspace.contains-${kind}`, { defaultValue: `${kind} {count}`, count: Number(count) } as any)))
|
|
4048
|
+
.join(' · ')
|
|
4049
|
+
const open = () => {
|
|
4050
|
+
if (!this.sessionId) return
|
|
4051
|
+
/* The page is the application's; navigate is the shell's, reached by an event so this component stays standalone. */
|
|
4052
|
+
this.dispatchEvent(new CustomEvent('assistant-open-page', { detail: { href: workspaceAddress(this.sessionId, p.workspace.name, version) }, bubbles: true, composed: true }))
|
|
4053
|
+
}
|
|
4054
|
+
return html`
|
|
4055
|
+
<div class="proposal workspace">
|
|
4056
|
+
<md-icon>${p.icon || 'dashboard'}</md-icon>
|
|
4057
|
+
<span class="pbody">
|
|
4058
|
+
<span class="plabel">${p.workspace.title}</span>
|
|
4059
|
+
<span class="preason">${[contents, of > 1 ? i18next.t('ai-assistant.workspace.version', { defaultValue: 'version {version} of {of}', version, of }) : ''].filter(Boolean).join(' · ')}</span>
|
|
4060
|
+
</span>
|
|
4061
|
+
<button class="run" ?disabled=${!this.sessionId} @click=${open}>${i18next.t('ai-assistant.workspace.open', { defaultValue: 'open in workspace' })}</button>
|
|
4062
|
+
</div>
|
|
4063
|
+
`
|
|
4064
|
+
}
|
|
4065
|
+
|
|
4038
4066
|
/**
|
|
4039
4067
|
* 눌린 카드가 말하는 것 — **호스트가 한 일**이고, 아직 모르면 모른다고 말한다.
|
|
4040
4068
|
*
|
|
@@ -12,7 +12,7 @@ import { client } from '@operato/graphql'
|
|
|
12
12
|
import { i18next, localize } from '@operato/i18n'
|
|
13
13
|
import { closeOverlay, notify } from '@operato/layout'
|
|
14
14
|
import { OxPrompt } from '@operato/popup'
|
|
15
|
-
import { AppController, store } from '@operato/shell'
|
|
15
|
+
import { AppController, navigate, store } from '@operato/shell'
|
|
16
16
|
import { ScrollbarStyles } from '@operato/styles'
|
|
17
17
|
|
|
18
18
|
import { followPickedConversation, type AssistantMode } from './assistant-mode.js'
|
|
@@ -329,6 +329,12 @@ export class AssistantDock extends localize(i18next)(LitElement) {
|
|
|
329
329
|
|
|
330
330
|
private onStartNewSession = () => void this.newConversation()
|
|
331
331
|
|
|
332
|
+
/* A card asks for a page of this application - the AI 작업판. The dock stays open beside it. */
|
|
333
|
+
private onOpenPage = (event: CustomEvent) => {
|
|
334
|
+
const href = event.detail?.href
|
|
335
|
+
if (typeof href === 'string' && href) navigate(href)
|
|
336
|
+
}
|
|
337
|
+
|
|
332
338
|
/** Who reads the open conversation - absent until the list or the create answer says. */
|
|
333
339
|
private get audience(): string | undefined {
|
|
334
340
|
return this.sessions.find(session => session.id === this.sessionId)?.visibility || undefined
|
|
@@ -344,6 +350,7 @@ export class AssistantDock extends localize(i18next)(LitElement) {
|
|
|
344
350
|
connectedCallback() {
|
|
345
351
|
super.connectedCallback()
|
|
346
352
|
this.addEventListener('assistant-start-new-session', this.onStartNewSession)
|
|
353
|
+
this.addEventListener('assistant-open-page', this.onOpenPage as EventListener)
|
|
347
354
|
this.unsubscribers.push(
|
|
348
355
|
subscribeAssistantSurface(() => this.follow()),
|
|
349
356
|
store.subscribe(() => { if (this.openedIdentity !== this.identityKey()) this.follow() }),
|
|
@@ -359,6 +366,7 @@ export class AssistantDock extends localize(i18next)(LitElement) {
|
|
|
359
366
|
disconnectedCallback() {
|
|
360
367
|
this.revision++
|
|
361
368
|
this.removeEventListener('assistant-start-new-session', this.onStartNewSession)
|
|
369
|
+
this.removeEventListener('assistant-open-page', this.onOpenPage as EventListener)
|
|
362
370
|
this.unsubscribers.splice(0).forEach(unsubscribe => unsubscribe())
|
|
363
371
|
setOpenSession('')
|
|
364
372
|
this.controller.clear()
|
|
@@ -103,6 +103,11 @@ export class AssistantEntryReview extends LitElement {
|
|
|
103
103
|
text-underline-offset: 3px;
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
.before {
|
|
107
|
+
color: var(--md-sys-color-on-surface-variant);
|
|
108
|
+
text-decoration: line-through;
|
|
109
|
+
}
|
|
110
|
+
|
|
106
111
|
.missing {
|
|
107
112
|
color: var(--md-sys-color-error);
|
|
108
113
|
font-style: italic;
|
|
@@ -182,8 +187,59 @@ export class AssistantEntryReview extends LitElement {
|
|
|
182
187
|
</div>`
|
|
183
188
|
}
|
|
184
189
|
|
|
190
|
+
/**
|
|
191
|
+
* A 수정안 - each record by its name, each changing field as what it holds now and what it becomes, and how the
|
|
192
|
+
* record was picked. The "now" is the server's reading, never the model's memory.
|
|
193
|
+
*/
|
|
194
|
+
private renderChanges(p: any) {
|
|
195
|
+
const columns: { name: string; type: string; labelKey?: string }[] = p.columns ?? []
|
|
196
|
+
const rows: any[] = p.rows ?? []
|
|
197
|
+
const counts = p.counts ?? {}
|
|
198
|
+
const via = (row: any) =>
|
|
199
|
+
row.via === 'current' ? t('via-current', 'the record on screen') : row.via === 'selected' ? t('via-selected', 'selected on screen') : t('via-id', 'found in the conversation')
|
|
200
|
+
return html`
|
|
201
|
+
<div class="summary" role="status">
|
|
202
|
+
<span>${t('changes', '{count} records', { count: counts.rows ?? rows.length })}</span>
|
|
203
|
+
<span>${t('ready', '{count} ready', { count: counts.ready ?? 0 })}</span>
|
|
204
|
+
${counts.blocked ? html`<span class="blocked">${t('not-changed', '{count} will not be changed', { count: counts.blocked })}</span>` : nothing}
|
|
205
|
+
</div>
|
|
206
|
+
<div class="scroll">
|
|
207
|
+
<table>
|
|
208
|
+
<thead>
|
|
209
|
+
<tr>
|
|
210
|
+
<th>#</th>
|
|
211
|
+
<th>${t('record', 'record')}</th>
|
|
212
|
+
${columns.map(column => html`<th>${column.labelKey ? i18next.t(column.labelKey) : column.name}</th>`)}
|
|
213
|
+
<th>${t('picked', 'picked')}</th>
|
|
214
|
+
</tr>
|
|
215
|
+
</thead>
|
|
216
|
+
<tbody>
|
|
217
|
+
${rows.slice(0, SHOWN).map(row => {
|
|
218
|
+
const blocked = (row.issues ?? []).some((issue: EntryIssueView) => issue.blocking)
|
|
219
|
+
const whole = entryCellIssues(row)
|
|
220
|
+
return html`<tr class=${blocked ? 'blocked' : ''}>
|
|
221
|
+
<td class="num">${row.index + 1}</td>
|
|
222
|
+
<td>${row.label || row.id}${whole.map(issue => html`<span class="row-issue ${issue.blocking ? 'blocking' : ''}">${issueText(issue)}</span>`)}</td>
|
|
223
|
+
${columns.map(column => {
|
|
224
|
+
const issues = entryCellIssues(row, column.name)
|
|
225
|
+
const bad = issues.some(issue => issue.blocking)
|
|
226
|
+
const changes = column.name in (row.values ?? {})
|
|
227
|
+
return html`<td class="${column.type === 'number' || column.type === 'integer' ? 'num' : ''} ${bad ? 'bad' : issues.length ? 'note' : ''}" title=${issues.map(issueText).join('\n')}>
|
|
228
|
+
${changes ? html`<span class="before">${shown(row.before?.[column.name]) || '—'}</span> → <strong>${shown(row.values[column.name]) || '—'}</strong>` : ''}
|
|
229
|
+
</td>`
|
|
230
|
+
})}
|
|
231
|
+
<td class="source">${via(row)}</td>
|
|
232
|
+
</tr>`
|
|
233
|
+
})}
|
|
234
|
+
</tbody>
|
|
235
|
+
</table>
|
|
236
|
+
</div>
|
|
237
|
+
`
|
|
238
|
+
}
|
|
239
|
+
|
|
185
240
|
render() {
|
|
186
241
|
const p = this.proposal ?? {}
|
|
242
|
+
if (p.op === 'update') return this.renderChanges(p)
|
|
187
243
|
const columns: { name: string; type: string; required?: boolean; labelKey?: string }[] = p.columns ?? []
|
|
188
244
|
const rows: EntryRowView[] = p.rows ?? []
|
|
189
245
|
const counts = p.counts ?? {}
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The blocks of an AI 작업판, drawn (operato-application/design/ai/workspace.md §3).
|
|
3
|
+
*
|
|
4
|
+
* Every kind is drawn here - the model writes no HTML. Charts use the eight validated categorical colours in
|
|
5
|
+
* a fixed order (dataviz reference palette, light and dark each checked); three of the light ones sit under
|
|
6
|
+
* 3:1 against the surface, so every chart carries its legend (two series or more) and a table view.
|
|
7
|
+
*/
|
|
8
|
+
import '@material/web/icon/icon.js'
|
|
9
|
+
|
|
10
|
+
import { LitElement, css, html, nothing, svg, type TemplateResult } from 'lit'
|
|
11
|
+
import { customElement, property, state } from 'lit/decorators.js'
|
|
12
|
+
import { unsafeHTML } from 'lit/directives/unsafe-html.js'
|
|
13
|
+
|
|
14
|
+
import { i18next } from '@operato/i18n'
|
|
15
|
+
|
|
16
|
+
import { renderMarkdown } from './markdown.js'
|
|
17
|
+
import { sortRows, tableCsv, type WorkspaceBlockView, type WorkspaceView } from '../utils/workspace.js'
|
|
18
|
+
|
|
19
|
+
const LIGHT = ['#2a78d6', '#eb6834', '#1baf7a', '#eda100', '#e87ba4', '#008300', '#4a3aa7', '#e34948']
|
|
20
|
+
const DARK = ['#3987e5', '#d95926', '#199e70', '#c98500', '#d55181', '#008300', '#9085e9', '#e66767']
|
|
21
|
+
|
|
22
|
+
const t = (key: string, defaultValue: string, options: Record<string, unknown> = {}) =>
|
|
23
|
+
String(i18next.t(`ai-assistant.workspace.${key}`, { defaultValue, ...options }))
|
|
24
|
+
|
|
25
|
+
const shown = (value: unknown) =>
|
|
26
|
+
value === null || value === undefined ? '' : typeof value === 'number' ? value.toLocaleString(i18next.language || undefined) : String(value)
|
|
27
|
+
|
|
28
|
+
/** Four round steps from zero to above the largest value. */
|
|
29
|
+
function ticksFor(max: number): number[] {
|
|
30
|
+
if (!(max > 0)) return [0, 1]
|
|
31
|
+
const raw = max / 4
|
|
32
|
+
const power = 10 ** Math.floor(Math.log10(raw))
|
|
33
|
+
const step = [1, 2, 2.5, 5, 10].map(one => one * power).find(one => one >= raw) ?? raw
|
|
34
|
+
return Array.from({ length: Math.ceil(max / step) + 1 }, (_, i) => Math.round(i * step * 1e6) / 1e6)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
@customElement('ox-assistant-workspace-view')
|
|
38
|
+
export class AssistantWorkspaceView extends LitElement {
|
|
39
|
+
static styles = css`
|
|
40
|
+
:host {
|
|
41
|
+
display: block;
|
|
42
|
+
color: var(--md-sys-color-on-surface);
|
|
43
|
+
font-size: 14px;
|
|
44
|
+
line-height: 1.5;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
section {
|
|
48
|
+
margin: 0 0 20px;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
h1,
|
|
52
|
+
h2,
|
|
53
|
+
h3 {
|
|
54
|
+
margin: 24px 0 8px;
|
|
55
|
+
line-height: 1.3;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
h1 {
|
|
59
|
+
font-size: 22px;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
h2 {
|
|
63
|
+
font-size: 18px;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
h3 {
|
|
67
|
+
font-size: 15px;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.metrics {
|
|
71
|
+
display: grid;
|
|
72
|
+
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
|
|
73
|
+
gap: 10px;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.metric {
|
|
77
|
+
padding: 12px 14px;
|
|
78
|
+
border: 1px solid var(--md-sys-color-outline-variant);
|
|
79
|
+
border-radius: 10px;
|
|
80
|
+
background: var(--md-sys-color-surface-container-lowest);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.metric .label {
|
|
84
|
+
color: var(--md-sys-color-on-surface-variant);
|
|
85
|
+
font-size: 12px;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.metric .value {
|
|
89
|
+
font-size: 22px;
|
|
90
|
+
font-weight: 650;
|
|
91
|
+
font-variant-numeric: tabular-nums;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.metric .note {
|
|
95
|
+
color: var(--md-sys-color-on-surface-variant);
|
|
96
|
+
font-size: 12px;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.bar {
|
|
100
|
+
display: flex;
|
|
101
|
+
align-items: center;
|
|
102
|
+
gap: 8px;
|
|
103
|
+
margin-bottom: 6px;
|
|
104
|
+
color: var(--md-sys-color-on-surface-variant);
|
|
105
|
+
font-size: 12px;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.bar .caption {
|
|
109
|
+
flex: 1;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.bar button {
|
|
113
|
+
display: inline-flex;
|
|
114
|
+
align-items: center;
|
|
115
|
+
gap: 4px;
|
|
116
|
+
padding: 3px 8px;
|
|
117
|
+
border: 1px solid var(--md-sys-color-outline-variant);
|
|
118
|
+
border-radius: 6px;
|
|
119
|
+
background: transparent;
|
|
120
|
+
color: inherit;
|
|
121
|
+
font: inherit;
|
|
122
|
+
cursor: pointer;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.bar md-icon {
|
|
126
|
+
--md-icon-size: 16px;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.scroll {
|
|
130
|
+
max-height: 560px;
|
|
131
|
+
overflow: auto;
|
|
132
|
+
border: 1px solid var(--md-sys-color-outline-variant);
|
|
133
|
+
border-radius: 8px;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
table {
|
|
137
|
+
width: 100%;
|
|
138
|
+
border-collapse: collapse;
|
|
139
|
+
font-size: 13px;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
th,
|
|
143
|
+
td {
|
|
144
|
+
padding: 6px 10px;
|
|
145
|
+
border-bottom: 1px solid var(--md-sys-color-outline-variant);
|
|
146
|
+
text-align: start;
|
|
147
|
+
white-space: nowrap;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
th {
|
|
151
|
+
position: sticky;
|
|
152
|
+
top: 0;
|
|
153
|
+
background: var(--md-sys-color-surface-container);
|
|
154
|
+
font-weight: 600;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
th button {
|
|
158
|
+
border: 0;
|
|
159
|
+
padding: 0;
|
|
160
|
+
background: none;
|
|
161
|
+
color: inherit;
|
|
162
|
+
font: inherit;
|
|
163
|
+
cursor: pointer;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
td.num,
|
|
167
|
+
th.num {
|
|
168
|
+
text-align: end;
|
|
169
|
+
font-variant-numeric: tabular-nums;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
.chart svg {
|
|
173
|
+
width: 100%;
|
|
174
|
+
height: auto;
|
|
175
|
+
display: block;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.chart .grid {
|
|
179
|
+
stroke: var(--md-sys-color-outline-variant);
|
|
180
|
+
stroke-width: 1;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
.chart .tick {
|
|
184
|
+
fill: var(--md-sys-color-on-surface-variant);
|
|
185
|
+
font-size: 11px;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
.legend {
|
|
189
|
+
display: flex;
|
|
190
|
+
flex-wrap: wrap;
|
|
191
|
+
gap: 4px 14px;
|
|
192
|
+
margin-top: 6px;
|
|
193
|
+
font-size: 12px;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
.legend .swatch {
|
|
197
|
+
display: inline-block;
|
|
198
|
+
width: 10px;
|
|
199
|
+
height: 10px;
|
|
200
|
+
margin-inline-end: 5px;
|
|
201
|
+
border-radius: 3px;
|
|
202
|
+
vertical-align: -1px;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
.steps {
|
|
206
|
+
list-style: none;
|
|
207
|
+
margin: 0;
|
|
208
|
+
padding: 0;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
.steps li {
|
|
212
|
+
display: flex;
|
|
213
|
+
gap: 8px;
|
|
214
|
+
padding: 4px 0;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
.steps md-icon {
|
|
218
|
+
--md-icon-size: 18px;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
.steps .done md-icon {
|
|
222
|
+
color: var(--md-sys-color-primary);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
.steps .failed md-icon {
|
|
226
|
+
color: var(--md-sys-color-error);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
.steps .note {
|
|
230
|
+
display: block;
|
|
231
|
+
color: var(--md-sys-color-on-surface-variant);
|
|
232
|
+
font-size: 12px;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
.unreadable,
|
|
236
|
+
.cut {
|
|
237
|
+
color: var(--md-sys-color-on-surface-variant);
|
|
238
|
+
font-size: 12px;
|
|
239
|
+
font-style: italic;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
.markdown :first-child {
|
|
243
|
+
margin-top: 0;
|
|
244
|
+
}
|
|
245
|
+
`
|
|
246
|
+
|
|
247
|
+
@property({ attribute: false }) workspace?: WorkspaceView
|
|
248
|
+
|
|
249
|
+
@state() private sorts: Record<number, { column: string; direction: 'asc' | 'desc' }> = {}
|
|
250
|
+
@state() private asTable = new Set<number>()
|
|
251
|
+
@state() private dark = false
|
|
252
|
+
|
|
253
|
+
private themeWatch?: MutationObserver
|
|
254
|
+
|
|
255
|
+
connectedCallback() {
|
|
256
|
+
super.connectedCallback()
|
|
257
|
+
this.dark = document.body.classList.contains('dark')
|
|
258
|
+
this.themeWatch = new MutationObserver(() => (this.dark = document.body.classList.contains('dark')))
|
|
259
|
+
this.themeWatch.observe(document.body, { attributes: true, attributeFilter: ['class'] })
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
disconnectedCallback() {
|
|
263
|
+
this.themeWatch?.disconnect()
|
|
264
|
+
super.disconnectedCallback()
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
render() {
|
|
268
|
+
const blocks = this.workspace?.blocks ?? []
|
|
269
|
+
return html`${blocks.map((block, i) => this.block(block, i))}`
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private block(block: WorkspaceBlockView, i: number): TemplateResult {
|
|
273
|
+
switch (block.kind) {
|
|
274
|
+
case 'heading':
|
|
275
|
+
return block.level === 1 ? html`<h1>${block.text}</h1>` : block.level === 3 ? html`<h3>${block.text}</h3>` : html`<h2>${block.text}</h2>`
|
|
276
|
+
case 'text':
|
|
277
|
+
return html`<section class="markdown">${unsafeHTML(renderMarkdown(block.markdown))}</section>`
|
|
278
|
+
case 'metrics':
|
|
279
|
+
return html`<section class="metrics">
|
|
280
|
+
${(block.items ?? []).map(
|
|
281
|
+
(one: any) => html`<div class="metric">
|
|
282
|
+
<div class="label">${one.label}</div>
|
|
283
|
+
<div class="value">${shown(one.value)}${one.unit ? html` <small>${one.unit}</small>` : nothing}</div>
|
|
284
|
+
${one.note ? html`<div class="note">${one.note}</div>` : nothing}
|
|
285
|
+
</div>`
|
|
286
|
+
)}
|
|
287
|
+
</section>`
|
|
288
|
+
case 'table':
|
|
289
|
+
return this.table(block, i)
|
|
290
|
+
case 'chart':
|
|
291
|
+
return this.chart(block, i)
|
|
292
|
+
case 'steps':
|
|
293
|
+
return html`<section>
|
|
294
|
+
<ol class="steps">
|
|
295
|
+
${(block.items ?? []).map(
|
|
296
|
+
(one: any) => html`<li class=${one.state}>
|
|
297
|
+
<md-icon>${{ done: 'check_circle', doing: 'progress_activity', failed: 'error', todo: 'radio_button_unchecked' }[one.state as string] ?? 'radio_button_unchecked'}</md-icon>
|
|
298
|
+
<span>${one.label}${one.note ? html`<span class="note">${one.note}</span>` : nothing}</span>
|
|
299
|
+
</li>`
|
|
300
|
+
)}
|
|
301
|
+
</ol>
|
|
302
|
+
</section>`
|
|
303
|
+
default:
|
|
304
|
+
return html`<section class="unreadable">${t('unreadable', 'a block that cannot be drawn ({reason})', { reason: block.reason ?? block.kind })}</section>`
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
private table(block: WorkspaceBlockView, i: number): TemplateResult {
|
|
309
|
+
const columns: { name: string; label?: string; type?: string }[] = block.columns ?? []
|
|
310
|
+
const sort = this.sorts[i]
|
|
311
|
+
const rows = sort ? sortRows(block.rows ?? [], sort.column, sort.direction, i18next.language) : block.rows ?? []
|
|
312
|
+
const toggle = (column: string) =>
|
|
313
|
+
(this.sorts = { ...this.sorts, [i]: { column, direction: sort?.column === column && sort.direction === 'asc' ? 'desc' : 'asc' } })
|
|
314
|
+
return html`<section>
|
|
315
|
+
<div class="bar">
|
|
316
|
+
<span class="caption">${block.caption ?? ''} · ${t('rows', '{count} rows', { count: rows.length })}</span>
|
|
317
|
+
<button @click=${() => this.download(block.caption || this.workspace?.title || 'table', columns, rows)}>
|
|
318
|
+
<md-icon>download</md-icon>${t('csv', 'CSV')}
|
|
319
|
+
</button>
|
|
320
|
+
</div>
|
|
321
|
+
${this.tableOf(columns, rows, sort, toggle)}
|
|
322
|
+
${block.cut ? html`<div class="cut">${t('cut', '{count} more rows were left out over the limit.', { count: block.cut })}</div>` : nothing}
|
|
323
|
+
</section>`
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private tableOf(columns: { name: string; label?: string; type?: string }[], rows: Record<string, any>[], sort?: { column: string; direction: string }, toggle?: (column: string) => void) {
|
|
327
|
+
return html`<div class="scroll">
|
|
328
|
+
<table>
|
|
329
|
+
<thead>
|
|
330
|
+
<tr>
|
|
331
|
+
${columns.map(
|
|
332
|
+
column => html`<th class=${column.type === 'number' ? 'num' : ''} aria-sort=${sort?.column === column.name ? (sort.direction === 'asc' ? 'ascending' : 'descending') : 'none'}>
|
|
333
|
+
${toggle
|
|
334
|
+
? html`<button @click=${() => toggle(column.name)}>${column.label || column.name}${sort?.column === column.name ? (sort.direction === 'asc' ? ' ▲' : ' ▼') : ''}</button>`
|
|
335
|
+
: column.label || column.name}
|
|
336
|
+
</th>`
|
|
337
|
+
)}
|
|
338
|
+
</tr>
|
|
339
|
+
</thead>
|
|
340
|
+
<tbody>
|
|
341
|
+
${rows.map(row => html`<tr>${columns.map(column => html`<td class=${column.type === 'number' || typeof row[column.name] === 'number' ? 'num' : ''}>${shown(row[column.name])}</td>`)}</tr>`)}
|
|
342
|
+
</tbody>
|
|
343
|
+
</table>
|
|
344
|
+
</div>`
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
private chart(block: WorkspaceBlockView, i: number): TemplateResult {
|
|
348
|
+
const colours = this.dark ? DARK : LIGHT
|
|
349
|
+
const x: string[] = block.x ?? []
|
|
350
|
+
const series: { name: string; other?: boolean; values: (number | null)[] }[] = block.series ?? []
|
|
351
|
+
const name = (one: { name: string; other?: boolean }) => (one.other ? t('other', 'other') : one.name)
|
|
352
|
+
const showingTable = this.asTable.has(i)
|
|
353
|
+
const flip = () => {
|
|
354
|
+
const next = new Set(this.asTable)
|
|
355
|
+
next.has(i) ? next.delete(i) : next.add(i)
|
|
356
|
+
this.asTable = next
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const bar = html`<div class="bar">
|
|
360
|
+
<span class="caption">${block.caption ?? ''}${block.unit ? ` (${block.unit})` : ''}</span>
|
|
361
|
+
<button @click=${flip}><md-icon>${showingTable ? 'bar_chart' : 'table_rows'}</md-icon>${showingTable ? t('as-chart', 'chart') : t('as-table', 'table')}</button>
|
|
362
|
+
</div>`
|
|
363
|
+
|
|
364
|
+
if (showingTable) {
|
|
365
|
+
const columns = [{ name: '_x', label: '' }, ...series.map((one, s) => ({ name: `s${s}`, label: name(one), type: 'number' }))]
|
|
366
|
+
const rows = x.map((label, j) => Object.fromEntries([['_x', label], ...series.map((one, s) => [`s${s}`, one.values[j]])]))
|
|
367
|
+
return html`<section class="chart">${bar}${this.tableOf(columns, rows)}</section>`
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const W = 640
|
|
371
|
+
const H = 240
|
|
372
|
+
const left = 56
|
|
373
|
+
const bottom = 28
|
|
374
|
+
const plotW = W - left - 8
|
|
375
|
+
const plotH = H - bottom - 10
|
|
376
|
+
const values = series.flatMap(one => one.values).filter((v): v is number => typeof v === 'number')
|
|
377
|
+
const min = Math.min(0, ...values)
|
|
378
|
+
const ticks = ticksFor(Math.max(...values, 0) - min).map(tick => tick + min)
|
|
379
|
+
const top = ticks[ticks.length - 1] || 1
|
|
380
|
+
const y = (v: number) => 10 + plotH - ((v - min) / (top - min || 1)) * plotH
|
|
381
|
+
const band = plotW / Math.max(1, x.length)
|
|
382
|
+
const everyNth = Math.max(1, Math.ceil(x.length / 12))
|
|
383
|
+
|
|
384
|
+
const marks =
|
|
385
|
+
block.chart === 'line'
|
|
386
|
+
? series.map((one, s) => {
|
|
387
|
+
const points = one.values.map((v, j) => (typeof v === 'number' ? [left + band * (j + 0.5), y(v)] : null))
|
|
388
|
+
const path = points.reduce((d, p, j) => (p ? `${d}${d && points[j - 1] ? 'L' : 'M'}${p[0]},${p[1]}` : d), '')
|
|
389
|
+
return svg`<path d=${path} fill="none" stroke=${colours[s % 8]} stroke-width="2" stroke-linejoin="round" stroke-linecap="round"></path>
|
|
390
|
+
${points.map((p, j) =>
|
|
391
|
+
p ? svg`<circle cx=${p[0]} cy=${p[1]} r="4" fill=${colours[s % 8]} stroke="var(--md-sys-color-surface)" stroke-width="2"><title>${name(one)} · ${x[j]}: ${shown(one.values[j])}</title></circle>` : nothing
|
|
392
|
+
)}`
|
|
393
|
+
})
|
|
394
|
+
: series.map((one, s) => {
|
|
395
|
+
const inner = band * 0.7
|
|
396
|
+
const width = Math.max(2, inner / series.length - 2)
|
|
397
|
+
return one.values.map((v, j) => {
|
|
398
|
+
if (typeof v !== 'number') return nothing
|
|
399
|
+
const x0 = left + band * j + (band - inner) / 2 + s * (width + 2)
|
|
400
|
+
const y0 = Math.min(y(v), y(0))
|
|
401
|
+
const h = Math.max(1, Math.abs(y(v) - y(0)))
|
|
402
|
+
return svg`<rect x=${x0} y=${y0} width=${width} height=${h} rx="3" fill=${colours[s % 8]}><title>${name(one)} · ${x[j]}: ${shown(v)}</title></rect>`
|
|
403
|
+
})
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
return html`<section class="chart" role="img" aria-label=${block.caption || series.map(name).join(', ')}>
|
|
407
|
+
${bar}
|
|
408
|
+
<svg viewBox="0 0 ${W} ${H}">
|
|
409
|
+
${ticks.map(tick => svg`<line class="grid" x1=${left} x2=${W - 8} y1=${y(tick)} y2=${y(tick)}></line><text class="tick" x=${left - 6} y=${y(tick) + 4} text-anchor="end">${shown(tick)}</text>`)}
|
|
410
|
+
${marks}
|
|
411
|
+
${x.map((label, j) => (j % everyNth ? nothing : svg`<text class="tick" x=${left + band * (j + 0.5)} y=${H - 8} text-anchor="middle">${label}</text>`))}
|
|
412
|
+
</svg>
|
|
413
|
+
${series.length > 1
|
|
414
|
+
? html`<div class="legend">${series.map((one, s) => html`<span><span class="swatch" style="background:${colours[s % 8]}"></span>${name(one)}</span>`)}</div>`
|
|
415
|
+
: nothing}
|
|
416
|
+
</section>`
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
private download(title: string, columns: { name: string; label?: string }[], rows: Record<string, any>[]) {
|
|
420
|
+
const blob = new Blob([tableCsv(columns, rows)], { type: 'text/csv;charset=utf-8' })
|
|
421
|
+
const url = URL.createObjectURL(blob)
|
|
422
|
+
const link = document.createElement('a')
|
|
423
|
+
link.href = url
|
|
424
|
+
link.download = `${title.replace(/[\\/:*?"<>|]+/g, '-').slice(0, 80)}.csv`
|
|
425
|
+
link.click()
|
|
426
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
|
427
|
+
}
|
|
428
|
+
}
|