@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.
Files changed (82) hide show
  1. package/client/components/assistant-chat.ts +31 -3
  2. package/client/components/assistant-dock.ts +9 -1
  3. package/client/components/entry-review.ts +56 -0
  4. package/client/components/workspace-view.ts +428 -0
  5. package/client/pages/assistant-workspace-page.ts +237 -0
  6. package/client/route.ts +9 -0
  7. package/client/utils/entry-proposal.ts +20 -5
  8. package/client/utils/workspace.ts +89 -0
  9. package/dist-client/components/assistant-chat.d.ts +2 -0
  10. package/dist-client/components/assistant-chat.js +32 -3
  11. package/dist-client/components/assistant-chat.js.map +1 -1
  12. package/dist-client/components/assistant-dock.d.ts +1 -0
  13. package/dist-client/components/assistant-dock.js +9 -1
  14. package/dist-client/components/assistant-dock.js.map +1 -1
  15. package/dist-client/components/entry-review.d.ts +5 -0
  16. package/dist-client/components/entry-review.js +55 -0
  17. package/dist-client/components/entry-review.js.map +1 -1
  18. package/dist-client/components/workspace-view.d.ts +19 -0
  19. package/dist-client/components/workspace-view.js +416 -0
  20. package/dist-client/components/workspace-view.js.map +1 -0
  21. package/dist-client/pages/assistant-workspace-page.d.ts +27 -0
  22. package/dist-client/pages/assistant-workspace-page.js +263 -0
  23. package/dist-client/pages/assistant-workspace-page.js.map +1 -0
  24. package/dist-client/route.d.ts +1 -0
  25. package/dist-client/route.js +10 -0
  26. package/dist-client/route.js.map +1 -0
  27. package/dist-client/tsconfig.tsbuildinfo +1 -1
  28. package/dist-client/utils/entry-proposal.d.ts +5 -0
  29. package/dist-client/utils/entry-proposal.js +16 -5
  30. package/dist-client/utils/entry-proposal.js.map +1 -1
  31. package/dist-client/utils/workspace.d.ts +40 -0
  32. package/dist-client/utils/workspace.js +68 -0
  33. package/dist-client/utils/workspace.js.map +1 -0
  34. package/dist-server/service/assistant-mode/resolve.d.ts +2 -2
  35. package/dist-server/service/assistant-mode/resolve.js +5 -3
  36. package/dist-server/service/assistant-mode/resolve.js.map +1 -1
  37. package/dist-server/service/data-entry/entry-apply.d.ts +3 -3
  38. package/dist-server/service/data-entry/entry-apply.js +55 -2
  39. package/dist-server/service/data-entry/entry-apply.js.map +1 -1
  40. package/dist-server/service/data-entry/entry-resolver.js +3 -7
  41. package/dist-server/service/data-entry/entry-resolver.js.map +1 -1
  42. package/dist-server/service/data-entry/entry-target.d.ts +51 -0
  43. package/dist-server/service/data-entry/entry-target.js +77 -34
  44. package/dist-server/service/data-entry/entry-target.js.map +1 -1
  45. package/dist-server/service/data-entry/entry-tools.d.ts +7 -1
  46. package/dist-server/service/data-entry/entry-tools.js +73 -3
  47. package/dist-server/service/data-entry/entry-tools.js.map +1 -1
  48. package/dist-server/service/data-entry/entry-update.d.ts +99 -0
  49. package/dist-server/service/data-entry/entry-update.js +196 -0
  50. package/dist-server/service/data-entry/entry-update.js.map +1 -0
  51. package/dist-server/service/index.d.ts +2 -0
  52. package/dist-server/service/index.js +3 -0
  53. package/dist-server/service/index.js.map +1 -1
  54. package/dist-server/service/workspace/workspace-blocks.d.ts +24 -0
  55. package/dist-server/service/workspace/workspace-blocks.js +118 -0
  56. package/dist-server/service/workspace/workspace-blocks.js.map +1 -0
  57. package/dist-server/service/workspace/workspace-tools.d.ts +24 -0
  58. package/dist-server/service/workspace/workspace-tools.js +65 -0
  59. package/dist-server/service/workspace/workspace-tools.js.map +1 -0
  60. package/dist-server/tsconfig.tsbuildinfo +1 -1
  61. package/package.json +2 -2
  62. package/server/service/assistant-mode/resolve.test.ts +2 -1
  63. package/server/service/assistant-mode/resolve.ts +5 -3
  64. package/server/service/data-entry/data-entry-update.test.ts +192 -0
  65. package/server/service/data-entry/data-entry.test.ts +2 -2
  66. package/server/service/data-entry/entry-apply.ts +63 -6
  67. package/server/service/data-entry/entry-resolver.ts +4 -9
  68. package/server/service/data-entry/entry-target.ts +97 -31
  69. package/server/service/data-entry/entry-tools.ts +77 -3
  70. package/server/service/data-entry/entry-update.ts +209 -0
  71. package/server/service/index.ts +3 -0
  72. package/server/service/workspace/workspace-blocks.ts +127 -0
  73. package/server/service/workspace/workspace-tools.ts +66 -0
  74. package/server/service/workspace/workspace.test.ts +58 -0
  75. package/test/entry-proposal.test.ts +15 -2
  76. package/test/workspace.test.ts +54 -0
  77. package/things-factory.config.js +6 -1
  78. package/translations/en.json +36 -1
  79. package/translations/ja.json +36 -1
  80. package/translations/ko.json +36 -1
  81. package/translations/ms.json +36 -1
  82. 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.target) !== key && card.state === 'ready'
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
- ? i18next.t('ai-assistant.entry.enter', { defaultValue: 'enter {count} rows', count: ready })
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
+ }