wani-graf 0.1.0
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/LICENSE +21 -0
- package/README.md +80 -0
- package/package.json +32 -0
- package/wani-graf.js +380 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 WaniGraf
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# wani-graf
|
|
2
|
+
|
|
3
|
+
Embeddable chart web component powered by [WaniGraf](https://wanigraf.com). Drop a live, AI-generated chart anywhere with a single HTML tag — no API key required.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install wani-graf
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
or via CDN:
|
|
12
|
+
|
|
13
|
+
```html
|
|
14
|
+
<script src="https://cdn.jsdelivr.net/npm/wani-graf/wani-graf.js"></script>
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
### Render a saved chart by key
|
|
20
|
+
|
|
21
|
+
```html
|
|
22
|
+
<wani-graf chart-key="abc123"></wani-graf>
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Create a chart at [wanigraf.com](https://wanigraf.com), copy the 10-char key from the share menu, done.
|
|
26
|
+
|
|
27
|
+
### Feed live external data into a saved chart
|
|
28
|
+
|
|
29
|
+
```html
|
|
30
|
+
<wani-graf chart-key="abc123" api-url="https://api.example.com/data"></wani-graf>
|
|
31
|
+
<wani-graf chart-key="abc123" data='[{"month":"Jan","revenue":1200}]'></wani-graf>
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Generate a chart on-the-fly from raw data (no saved chart needed)
|
|
35
|
+
|
|
36
|
+
```html
|
|
37
|
+
<wani-graf data='[{"month":"Jan","revenue":1200}]'></wani-graf>
|
|
38
|
+
<wani-graf api-url="https://api.example.com/data" chart-hint="bar"></wani-graf>
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Attributes
|
|
42
|
+
|
|
43
|
+
| Attribute | Description |
|
|
44
|
+
|---|---|
|
|
45
|
+
| `chart-key` | Chart ID from wanigraf.com (10-char code) |
|
|
46
|
+
| `data` | JSON string of an array of objects |
|
|
47
|
+
| `api-url` | URL to fetch JSON data from (proxied server-side, CORS-safe) |
|
|
48
|
+
| `chart-hint` | Preferred chart type for data-only mode: `line` \| `bar` \| `pie` |
|
|
49
|
+
| `height` | Height in px (default: `400`) |
|
|
50
|
+
| `base-url` | Override wanigraf.com base URL (for self-hosted / dev) |
|
|
51
|
+
|
|
52
|
+
## React
|
|
53
|
+
|
|
54
|
+
```jsx
|
|
55
|
+
import 'wani-graf'
|
|
56
|
+
|
|
57
|
+
export default function Chart() {
|
|
58
|
+
return <wani-graf chart-key="abc123" height="300" />
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
On React 18 and below, use a ref if attributes don't apply:
|
|
63
|
+
|
|
64
|
+
```jsx
|
|
65
|
+
import { useEffect, useRef } from 'react'
|
|
66
|
+
import 'wani-graf'
|
|
67
|
+
|
|
68
|
+
export default function Chart() {
|
|
69
|
+
const ref = useRef()
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
ref.current.setAttribute('chart-key', 'abc123')
|
|
72
|
+
ref.current.setAttribute('height', '300')
|
|
73
|
+
}, [])
|
|
74
|
+
return <wani-graf ref={ref} />
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## License
|
|
79
|
+
|
|
80
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "wani-graf",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Embeddable chart web component powered by WaniGraf AI",
|
|
5
|
+
"main": "wani-graf.js",
|
|
6
|
+
"module": "wani-graf.js",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./wani-graf.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"wani-graf.js",
|
|
13
|
+
"README.md",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"bugs": {
|
|
17
|
+
"url": "https://github.com/wanigraf/wani-graf/issues"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"chart",
|
|
21
|
+
"web-component",
|
|
22
|
+
"custom-element",
|
|
23
|
+
"data-visualization",
|
|
24
|
+
"wanigraf"
|
|
25
|
+
],
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "https://github.com/wanigraf/wani-graf"
|
|
30
|
+
},
|
|
31
|
+
"homepage": "https://wanigraf.com"
|
|
32
|
+
}
|
package/wani-graf.js
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <wani-graf> — WaniGraf embeddable chart web component
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* <!-- Render a saved chart by key -->
|
|
6
|
+
* <wani-graf chart-key="abc123"></wani-graf>
|
|
7
|
+
*
|
|
8
|
+
* <!-- Render a saved chart but feed it live external data (data bridge) -->
|
|
9
|
+
* <wani-graf chart-key="abc123" api-url="https://api.example.com/data"></wani-graf>
|
|
10
|
+
* <wani-graf chart-key="abc123" data='[{"month":"Jan","revenue":1200}]'></wani-graf>
|
|
11
|
+
*
|
|
12
|
+
* <!-- Generate a chart on-the-fly from raw data (no saved chart needed) -->
|
|
13
|
+
* <wani-graf data='[{"month":"Jan","revenue":1200}]'></wani-graf>
|
|
14
|
+
* <wani-graf api-url="https://api.example.com/data" chart-hint="bar"></wani-graf>
|
|
15
|
+
*
|
|
16
|
+
* Attributes:
|
|
17
|
+
* chart-key — The chart ID from wanigraf.com (10-char code)
|
|
18
|
+
* data — JSON string of an array of objects
|
|
19
|
+
* api-url — URL to fetch JSON data from (server-side CORS-safe fetch via our proxy)
|
|
20
|
+
* chart-hint — Preferred chart type hint for data-only mode: "line" | "bar" | "pie"
|
|
21
|
+
* height — Height in px (default: 400)
|
|
22
|
+
* base-url — Override wanigraf.com base URL (for self-hosted / dev)
|
|
23
|
+
*
|
|
24
|
+
* Install:
|
|
25
|
+
* npm install wani-graf
|
|
26
|
+
* import 'wani-graf'
|
|
27
|
+
*
|
|
28
|
+
* or via CDN:
|
|
29
|
+
* <script src="https://cdn.jsdelivr.net/npm/wani-graf/wani-graf.js"></script>
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
(function () {
|
|
33
|
+
'use strict'
|
|
34
|
+
|
|
35
|
+
// Capture the script's own origin at load time (works because this IIFE runs synchronously).
|
|
36
|
+
// This means: when served from localhost:3000/wani-graf.js → base is localhost:3000.
|
|
37
|
+
// when served from wanigraf.com/wani-graf.js → base is wanigraf.com.
|
|
38
|
+
// when loaded from CDN (jsdelivr, etc.) → falls back to wanigraf.com.
|
|
39
|
+
const DEFAULT_BASE = (() => {
|
|
40
|
+
try {
|
|
41
|
+
const src = document.currentScript?.src
|
|
42
|
+
if (src) {
|
|
43
|
+
const origin = new URL(src).origin
|
|
44
|
+
// Don't use CDN origins as base — they don't host the API
|
|
45
|
+
if (!origin.includes('jsdelivr') && !origin.includes('unpkg') && !origin.includes('cdn')) {
|
|
46
|
+
return origin
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
} catch {}
|
|
50
|
+
return 'https://wanigraf.com'
|
|
51
|
+
})()
|
|
52
|
+
|
|
53
|
+
const CSS = `
|
|
54
|
+
:host {
|
|
55
|
+
display: block;
|
|
56
|
+
width: 100%;
|
|
57
|
+
height: var(--wg-height, 400px);
|
|
58
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
59
|
+
}
|
|
60
|
+
.wg-wrap {
|
|
61
|
+
width: 100%;
|
|
62
|
+
height: 100%;
|
|
63
|
+
position: relative;
|
|
64
|
+
border-radius: 8px;
|
|
65
|
+
overflow: hidden;
|
|
66
|
+
background: #1a1a2e;
|
|
67
|
+
}
|
|
68
|
+
iframe {
|
|
69
|
+
width: 100%;
|
|
70
|
+
height: 100%;
|
|
71
|
+
border: none;
|
|
72
|
+
display: block;
|
|
73
|
+
}
|
|
74
|
+
.wg-state {
|
|
75
|
+
display: flex;
|
|
76
|
+
flex-direction: column;
|
|
77
|
+
align-items: center;
|
|
78
|
+
justify-content: center;
|
|
79
|
+
width: 100%;
|
|
80
|
+
height: 100%;
|
|
81
|
+
gap: 12px;
|
|
82
|
+
font-size: 13px;
|
|
83
|
+
color: #888;
|
|
84
|
+
}
|
|
85
|
+
.wg-spinner {
|
|
86
|
+
width: 28px;
|
|
87
|
+
height: 28px;
|
|
88
|
+
border: 3px solid rgba(255,255,255,0.08);
|
|
89
|
+
border-top-color: #7d6eff;
|
|
90
|
+
border-radius: 50%;
|
|
91
|
+
animation: wg-spin 0.75s linear infinite;
|
|
92
|
+
}
|
|
93
|
+
@keyframes wg-spin { to { transform: rotate(360deg); } }
|
|
94
|
+
.wg-error { color: #f87171; text-align: center; padding: 0 16px; }
|
|
95
|
+
.wg-error-icon { font-size: 22px; }
|
|
96
|
+
`
|
|
97
|
+
|
|
98
|
+
class WaniGraf extends HTMLElement {
|
|
99
|
+
static get observedAttributes() {
|
|
100
|
+
return ['chart-key', 'data', 'api-url', 'chart-hint', 'height', 'base-url', 'site-key']
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
constructor() {
|
|
104
|
+
super()
|
|
105
|
+
this._shadow = this.attachShadow({ mode: 'open' })
|
|
106
|
+
this._msgHandler = null
|
|
107
|
+
this._iframe = null
|
|
108
|
+
this._pending = null
|
|
109
|
+
this._initialized = false
|
|
110
|
+
this._resizeObserver = null
|
|
111
|
+
this._wrap = null
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
connectedCallback() {
|
|
115
|
+
this._render()
|
|
116
|
+
this._init()
|
|
117
|
+
this._initialized = true
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
disconnectedCallback() {
|
|
121
|
+
if (this._msgHandler) {
|
|
122
|
+
window.removeEventListener('message', this._msgHandler)
|
|
123
|
+
this._msgHandler = null
|
|
124
|
+
}
|
|
125
|
+
if (this._resizeObserver) {
|
|
126
|
+
this._resizeObserver.disconnect()
|
|
127
|
+
this._resizeObserver = null
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
attributeChangedCallback() {
|
|
132
|
+
if (this._initialized) this._init()
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ─── Internal ───────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
_render() {
|
|
138
|
+
const height = this.getAttribute('height') || '400'
|
|
139
|
+
this._shadow.innerHTML = `
|
|
140
|
+
<style>${CSS}</style>
|
|
141
|
+
<div class="wg-wrap" style="--wg-height:${parseInt(height, 10)}px">
|
|
142
|
+
<div class="wg-state">
|
|
143
|
+
<div class="wg-spinner"></div>
|
|
144
|
+
<span>Loading…</span>
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
`
|
|
148
|
+
this._wrap = this._shadow.querySelector('.wg-wrap')
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Returns the current pixel dimensions of the wrapper div
|
|
152
|
+
_wrapSize() {
|
|
153
|
+
if (!this._wrap) return { w: 0, h: 0 }
|
|
154
|
+
const r = this._wrap.getBoundingClientRect()
|
|
155
|
+
return { w: Math.round(r.width), h: Math.round(r.height) }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Observe wrap size and forward changes to the iframe
|
|
159
|
+
_startResizeObserver() {
|
|
160
|
+
if (this._resizeObserver) this._resizeObserver.disconnect()
|
|
161
|
+
if (typeof ResizeObserver === 'undefined') return
|
|
162
|
+
this._resizeObserver = new ResizeObserver(() => {
|
|
163
|
+
if (!this._iframe?.contentWindow) return
|
|
164
|
+
const { w, h } = this._wrapSize()
|
|
165
|
+
if (w && h) {
|
|
166
|
+
this._iframe.contentWindow.postMessage(
|
|
167
|
+
{ type: 'wanigraf:resize', width: w, height: h }, '*'
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
})
|
|
171
|
+
this._resizeObserver.observe(this._wrap)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async _init() {
|
|
175
|
+
// Clean up previous listeners / observers
|
|
176
|
+
if (this._msgHandler) {
|
|
177
|
+
window.removeEventListener('message', this._msgHandler)
|
|
178
|
+
this._msgHandler = null
|
|
179
|
+
}
|
|
180
|
+
if (this._resizeObserver) {
|
|
181
|
+
this._resizeObserver.disconnect()
|
|
182
|
+
this._resizeObserver = null
|
|
183
|
+
}
|
|
184
|
+
this._pending = null
|
|
185
|
+
this._iframe = null
|
|
186
|
+
|
|
187
|
+
this._showLoading()
|
|
188
|
+
|
|
189
|
+
const base = (this.getAttribute('base-url') || DEFAULT_BASE).replace(/\/$/, '')
|
|
190
|
+
const chartKey = this.getAttribute('chart-key') || ''
|
|
191
|
+
const dataAttr = this.getAttribute('data') || ''
|
|
192
|
+
const apiUrl = this.getAttribute('api-url') || ''
|
|
193
|
+
const hint = this.getAttribute('chart-hint') || ''
|
|
194
|
+
const siteKey = this.getAttribute('site-key') || ''
|
|
195
|
+
|
|
196
|
+
// Resolve external data if needed
|
|
197
|
+
let data = null
|
|
198
|
+
if (apiUrl) {
|
|
199
|
+
data = await this._fetchApiData(base, apiUrl)
|
|
200
|
+
if (data === null) return // error already shown
|
|
201
|
+
} else if (dataAttr) {
|
|
202
|
+
try {
|
|
203
|
+
data = JSON.parse(dataAttr)
|
|
204
|
+
if (!Array.isArray(data)) throw new Error()
|
|
205
|
+
} catch {
|
|
206
|
+
this._showError('Invalid JSON in <code>data</code> attribute — must be an array.')
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (chartKey && !data) {
|
|
212
|
+
// ── Mode 1: Key only — plain iframe embed ──────────────────────────
|
|
213
|
+
this._mountIframe(`${base}/embed/${chartKey}`)
|
|
214
|
+
|
|
215
|
+
} else if (chartKey && data) {
|
|
216
|
+
// ── Mode 2: Key + data — data bridge ──────────────────────────────
|
|
217
|
+
let translator = null
|
|
218
|
+
try {
|
|
219
|
+
const body = { key: chartKey, data }
|
|
220
|
+
if (siteKey) body.siteKey = siteKey
|
|
221
|
+
const res = await this._post(`${base}/api/public/bridge`, body)
|
|
222
|
+
translator = res.translator ?? null
|
|
223
|
+
} catch (e) {
|
|
224
|
+
this._showError(`Bridge error: ${e.message}`)
|
|
225
|
+
return
|
|
226
|
+
}
|
|
227
|
+
this._pending = { type: 'wanigraf:inject', data, translator }
|
|
228
|
+
this._mountIframe(`${base}/embed/${chartKey}`, true)
|
|
229
|
+
|
|
230
|
+
} else if (data) {
|
|
231
|
+
// ── Mode 3: Data only — AI-generated ephemeral chart ───────────────
|
|
232
|
+
// Check localStorage cache first (same data → skip AI)
|
|
233
|
+
const dataHash = await this._hashData(data)
|
|
234
|
+
const cacheKey = `wg:${dataHash}:${hint}`
|
|
235
|
+
const cached = this._readCache(cacheKey)
|
|
236
|
+
if (cached) {
|
|
237
|
+
this._pending = { type: 'wanigraf:config', chartType: cached.chartType, chartData: cached.chartData }
|
|
238
|
+
this._mountIframe(`${base}/embed/preview`, true)
|
|
239
|
+
return
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
let config
|
|
243
|
+
try {
|
|
244
|
+
const body = { data, hint: hint || undefined, dataHash }
|
|
245
|
+
if (siteKey) body.siteKey = siteKey
|
|
246
|
+
config = await this._post(`${base}/api/public/chart-from-data`, body)
|
|
247
|
+
} catch (e) {
|
|
248
|
+
this._showError(`Chart generation failed: ${e.message}`)
|
|
249
|
+
return
|
|
250
|
+
}
|
|
251
|
+
// Cache the result so repeat renders skip the AI call
|
|
252
|
+
this._writeCache(cacheKey, { chartType: config.chartType, chartData: config.chartData })
|
|
253
|
+
this._pending = { type: 'wanigraf:config', chartType: config.chartType, chartData: config.chartData }
|
|
254
|
+
this._mountIframe(`${base}/embed/preview`, true)
|
|
255
|
+
|
|
256
|
+
} else {
|
|
257
|
+
this._showError('Provide at least one of: <code>chart-key</code>, <code>data</code>, or <code>api-url</code>.')
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async _fetchApiData(base, apiUrl) {
|
|
262
|
+
// Route through our server-side proxy to avoid browser CORS issues
|
|
263
|
+
try {
|
|
264
|
+
const res = await fetch(`${base}/api/fetch-json`, {
|
|
265
|
+
method: 'POST',
|
|
266
|
+
headers: { 'Content-Type': 'application/json' },
|
|
267
|
+
body: JSON.stringify({ url: apiUrl }),
|
|
268
|
+
})
|
|
269
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
270
|
+
const json = await res.json()
|
|
271
|
+
// Normalize: if root is an object with an array inside, try to unwrap
|
|
272
|
+
if (Array.isArray(json)) return json
|
|
273
|
+
// Try common wrapper keys
|
|
274
|
+
for (const key of ['data', 'results', 'items', 'rows', 'records']) {
|
|
275
|
+
if (Array.isArray(json[key])) return json[key]
|
|
276
|
+
}
|
|
277
|
+
return [json] // fallback: wrap single object
|
|
278
|
+
} catch (e) {
|
|
279
|
+
this._showError(`Failed to fetch api-url: ${e.message}`)
|
|
280
|
+
return null
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
_mountIframe(src, awaitReady = false) {
|
|
285
|
+
// Append current dimensions to the URL so the embed page has them immediately
|
|
286
|
+
const { w, h } = this._wrapSize()
|
|
287
|
+
const url = new URL(src, location.href)
|
|
288
|
+
if (w) url.searchParams.set('w', w)
|
|
289
|
+
if (h) url.searchParams.set('h', h)
|
|
290
|
+
|
|
291
|
+
const iframe = document.createElement('iframe')
|
|
292
|
+
iframe.src = url.toString()
|
|
293
|
+
iframe.setAttribute('scrolling', 'no')
|
|
294
|
+
iframe.setAttribute('allowtransparency', 'true')
|
|
295
|
+
iframe.setAttribute('loading', 'lazy')
|
|
296
|
+
this._iframe = iframe
|
|
297
|
+
|
|
298
|
+
if (awaitReady && this._pending) {
|
|
299
|
+
const pending = this._pending
|
|
300
|
+
this._msgHandler = (e) => {
|
|
301
|
+
if (e.source !== iframe.contentWindow) return
|
|
302
|
+
if (e.data?.type === 'wanigraf:ready') {
|
|
303
|
+
iframe.contentWindow.postMessage(pending, '*')
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
window.addEventListener('message', this._msgHandler)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
this._wrap.innerHTML = ''
|
|
310
|
+
this._wrap.appendChild(iframe)
|
|
311
|
+
|
|
312
|
+
// Start forwarding resize events to the iframe
|
|
313
|
+
this._startResizeObserver()
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ─── Caching helpers ─────────────────────────────────────────────────────
|
|
317
|
+
|
|
318
|
+
async _hashData(data) {
|
|
319
|
+
try {
|
|
320
|
+
const str = JSON.stringify(data)
|
|
321
|
+
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(str))
|
|
322
|
+
return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('').slice(0, 16)
|
|
323
|
+
} catch {
|
|
324
|
+
// Fallback: simple string hash
|
|
325
|
+
const str = JSON.stringify(data)
|
|
326
|
+
let h = 0
|
|
327
|
+
for (let i = 0; i < str.length; i++) h = Math.imul(31, h) + str.charCodeAt(i) | 0
|
|
328
|
+
return (h >>> 0).toString(16)
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
_readCache(key) {
|
|
333
|
+
try {
|
|
334
|
+
const raw = localStorage.getItem(key)
|
|
335
|
+
if (!raw) return null
|
|
336
|
+
const { v, ts } = JSON.parse(raw)
|
|
337
|
+
// Cache TTL: 24 hours
|
|
338
|
+
if (Date.now() - ts > 86_400_000) { localStorage.removeItem(key); return null }
|
|
339
|
+
return v
|
|
340
|
+
} catch { return null }
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
_writeCache(key, value) {
|
|
344
|
+
try { localStorage.setItem(key, JSON.stringify({ v: value, ts: Date.now() })) } catch {}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async _post(url, body) {
|
|
348
|
+
const res = await fetch(url, {
|
|
349
|
+
method: 'POST',
|
|
350
|
+
headers: { 'Content-Type': 'application/json' },
|
|
351
|
+
body: JSON.stringify(body),
|
|
352
|
+
})
|
|
353
|
+
const json = await res.json()
|
|
354
|
+
if (!res.ok) throw new Error(json.statusMessage || json.message || `HTTP ${res.status}`)
|
|
355
|
+
return json
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
_showLoading() {
|
|
359
|
+
this._wrap.innerHTML = `
|
|
360
|
+
<div class="wg-state">
|
|
361
|
+
<div class="wg-spinner"></div>
|
|
362
|
+
<span>Loading…</span>
|
|
363
|
+
</div>
|
|
364
|
+
`
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
_showError(msg) {
|
|
368
|
+
this._wrap.innerHTML = `
|
|
369
|
+
<div class="wg-state">
|
|
370
|
+
<span class="wg-error-icon">⚠️</span>
|
|
371
|
+
<span class="wg-error">${msg}</span>
|
|
372
|
+
</div>
|
|
373
|
+
`
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (!customElements.get('wani-graf')) {
|
|
378
|
+
customElements.define('wani-graf', WaniGraf)
|
|
379
|
+
}
|
|
380
|
+
})()
|