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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +80 -0
  3. package/package.json +32 -0
  4. 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
+ })()