@things-factory/ai-assistant 10.1.90 → 10.1.91
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 +26 -0
- package/client/components/assistant-dock.ts +9 -1
- 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/workspace.ts +89 -0
- package/dist-client/components/assistant-chat.d.ts +2 -0
- package/dist-client/components/assistant-chat.js +27 -0
- 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/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/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/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.test.ts +2 -2
- 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/workspace.test.ts +54 -0
- package/things-factory.config.js +6 -1
- package/translations/en.json +21 -1
- package/translations/ja.json +21 -1
- package/translations/ko.json +21 -1
- package/translations/ms.json +21 -1
- package/translations/zh.json +21 -1
|
@@ -65,6 +65,7 @@ import { parseProposalOutcomeLine, parseProposalRequestLine, proposalCardState,
|
|
|
65
65
|
import { availableTriggers, type TriggerChip } from './chat-triggers.js'
|
|
66
66
|
import './entry-review.js'
|
|
67
67
|
import { entryReadyCount, 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,6 +3954,8 @@ 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
3961
|
const replaced = entry && latestEntry?.get(p.target) !== key && card.state === 'ready'
|
|
@@ -4035,6 +4038,29 @@ export class OxAssistantChat extends LitElement {
|
|
|
4035
4038
|
`
|
|
4036
4039
|
}
|
|
4037
4040
|
|
|
4041
|
+
/** The AI 작업판 card - what it holds, which version, and the way to open it. The person opens it; the model does not. */
|
|
4042
|
+
private renderWorkspaceCard(p: any, key: string) {
|
|
4043
|
+
const { version, of } = versionOfCard(this.lines.map(one => one.proposals ?? []), p.workspace.name, key)
|
|
4044
|
+
const contents = Object.entries(p.contents ?? {})
|
|
4045
|
+
.map(([kind, count]) => String(i18next.t(`ai-assistant.workspace.contains-${kind}`, { defaultValue: `${kind} {count}`, count: Number(count) } as any)))
|
|
4046
|
+
.join(' · ')
|
|
4047
|
+
const open = () => {
|
|
4048
|
+
if (!this.sessionId) return
|
|
4049
|
+
/* The page is the application's; navigate is the shell's, reached by an event so this component stays standalone. */
|
|
4050
|
+
this.dispatchEvent(new CustomEvent('assistant-open-page', { detail: { href: workspaceAddress(this.sessionId, p.workspace.name, version) }, bubbles: true, composed: true }))
|
|
4051
|
+
}
|
|
4052
|
+
return html`
|
|
4053
|
+
<div class="proposal workspace">
|
|
4054
|
+
<md-icon>${p.icon || 'dashboard'}</md-icon>
|
|
4055
|
+
<span class="pbody">
|
|
4056
|
+
<span class="plabel">${p.workspace.title}</span>
|
|
4057
|
+
<span class="preason">${[contents, of > 1 ? i18next.t('ai-assistant.workspace.version', { defaultValue: 'version {version} of {of}', version, of }) : ''].filter(Boolean).join(' · ')}</span>
|
|
4058
|
+
</span>
|
|
4059
|
+
<button class="run" ?disabled=${!this.sessionId} @click=${open}>${i18next.t('ai-assistant.workspace.open', { defaultValue: 'open in workspace' })}</button>
|
|
4060
|
+
</div>
|
|
4061
|
+
`
|
|
4062
|
+
}
|
|
4063
|
+
|
|
4038
4064
|
/**
|
|
4039
4065
|
* 눌린 카드가 말하는 것 — **호스트가 한 일**이고, 아직 모르면 모른다고 말한다.
|
|
4040
4066
|
*
|
|
@@ -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()
|
|
@@ -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
|
+
}
|