|
class AiChart extends HTMLElement { |
|
static observedAttributes = ['type', 'data-function', 'data-src', 'series', 'width', 'height', 'label-step', 'x-label-function', 'y-label-function']; |
|
|
|
constructor() { |
|
super(); |
|
this.attachShadow({ mode: 'open' }); |
|
this._data = {}; |
|
this._xLabelFn = null; |
|
this._yLabelFn = null; |
|
} |
|
|
|
set xLabelFunction(fn) { this._xLabelFn = fn; this._draw(); } |
|
set yLabelFunction(fn) { this._yLabelFn = fn; this._draw(); } |
|
|
|
connectedCallback() { |
|
this._style = document.createElement('style'); |
|
this.shadowRoot.appendChild(this._style); |
|
this._svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); |
|
this.shadowRoot.appendChild(this._svg); |
|
this._resizeObserver = new ResizeObserver(() => this._draw()); |
|
this._resizeObserver.observe(this); |
|
this._fetchData(); |
|
} |
|
|
|
disconnectedCallback() { |
|
this._resizeObserver?.disconnect(); |
|
} |
|
|
|
attributeChangedCallback(name, oldVal, newVal) { |
|
if (!this.isConnected || oldVal === newVal) return; |
|
if (name === 'data-function' || name === 'data-src') { |
|
this._fetchData(); |
|
} else { |
|
this._draw(); |
|
} |
|
} |
|
|
|
get _type() { return this.getAttribute('type') || 'bar'; } |
|
|
|
get _series() { |
|
try { return JSON.parse(this.getAttribute('series') || '{}'); } catch { return {}; } |
|
} |
|
|
|
get _widthAttr() { return this.getAttribute('width') || 'auto'; } |
|
get _heightAttr() { return this.getAttribute('height') || 'auto'; } |
|
get _labelStep() { |
|
const css = parseInt(getComputedStyle(this).getPropertyValue('--chart-label-step').trim(), 10); |
|
if (css > 0) return css; |
|
return Math.max(1, parseInt(this.getAttribute('label-step') || '1', 10) || 1); |
|
} |
|
|
|
_getXFormatter() { |
|
if (typeof this._xLabelFn === 'function') return this._xLabelFn; |
|
const name = this.getAttribute('x-label-function'); |
|
if (name && typeof window[name] === 'function') return window[name]; |
|
return i => i; |
|
} |
|
|
|
_getYFormatter() { |
|
if (typeof this._yLabelFn === 'function') return this._yLabelFn; |
|
const name = this.getAttribute('y-label-function'); |
|
if (name && typeof window[name] === 'function') return window[name]; |
|
return val => val % 1 === 0 ? val : val.toFixed(1); |
|
} |
|
|
|
_getSize() { |
|
const rect = this.getBoundingClientRect(); |
|
const w = this._widthAttr === 'auto' ? rect.width : parseFloat(this._widthAttr); |
|
const h = this._heightAttr === 'auto' ? rect.height : parseFloat(this._heightAttr); |
|
return { width: w || 400, height: h || 200 }; |
|
} |
|
|
|
async _fetchData() { |
|
this._data = {}; |
|
const fnAttr = this.getAttribute('data-function'); |
|
const srcAttr = this.getAttribute('data-src'); |
|
|
|
if (fnAttr) { |
|
const isGen = fnAttr.startsWith('*'); |
|
const fn = window[isGen ? fnAttr.slice(1) : fnAttr]; |
|
if (typeof fn !== 'function') return; |
|
|
|
if (isGen) { |
|
for (const chunk of fn()) { |
|
this._mergeData(chunk); |
|
this._draw(); |
|
} |
|
} else { |
|
this._data = await Promise.resolve(fn()); |
|
} |
|
} else if (srcAttr) { |
|
this._data = await fetch(srcAttr).then(r => r.json()); |
|
} else { |
|
return; |
|
} |
|
|
|
this._draw(); |
|
} |
|
|
|
_mergeData(chunk) { |
|
for (const key of Object.keys(chunk)) { |
|
if (!this._data[key]) this._data[key] = []; |
|
this._data[key].push(...chunk[key]); |
|
} |
|
} |
|
|
|
_seriesKeys() { |
|
const configKeys = Object.keys(this._series); |
|
return configKeys.length ? configKeys : Object.keys(this._data); |
|
} |
|
|
|
_draw() { |
|
if (!this._svg) return; |
|
const keys = this._seriesKeys(); |
|
if (!keys.length) return; |
|
|
|
const { width, height } = this._getSize(); |
|
if (width <= 0 || height <= 0) return; |
|
|
|
const config = this._series; |
|
const hasLegend = Object.keys(config).length > 0; |
|
const pad = { top: hasLegend ? 35 : 20, right: 20, bottom: 40, left: 50 }; |
|
const chartW = width - pad.left - pad.right; |
|
const chartH = height - pad.top - pad.bottom; |
|
|
|
const hostW = this._widthAttr === 'auto' ? '100%' : this._widthAttr; |
|
const hostH = this._heightAttr === 'auto' ? '100%' : this._heightAttr; |
|
const seriesCss = keys.map(k => |
|
`[series="${k}"]{fill:var(--chart-series-${k});stroke:var(--chart-series-${k})}` |
|
).join(''); |
|
this._style.textContent = |
|
`:host{display:block;width:${hostW};height:${hostH}}` + |
|
`svg{display:block;width:100%;height:100%}` + |
|
`.axis-label,.legend-text{font:11px sans-serif}` + |
|
`.axis-label{fill:currentColor}` + |
|
seriesCss + |
|
`path[series]{fill:none}` + |
|
`.legend-text{fill:currentColor;stroke:none}`; |
|
|
|
this._svg.setAttribute('viewBox', `0 0 ${width} ${height}`); |
|
this._svg.innerHTML = ''; |
|
|
|
const allVals = keys.flatMap(k => this._data[k] || []); |
|
if (!allVals.length) return; |
|
|
|
const maxVal = Math.max(...allVals, 0); |
|
const minVal = Math.min(...allVals, 0); |
|
const range = maxVal - minVal || 1; |
|
const maxPts = Math.max(...keys.map(k => (this._data[k] || []).length)); |
|
|
|
const toY = v => pad.top + chartH - ((v - minVal) / range) * chartH; |
|
const toX = i => pad.left + (maxPts > 1 ? (i / (maxPts - 1)) * chartW : chartW / 2); |
|
|
|
const yFmt = this._getYFormatter(); |
|
for (let i = 0; i <= 5; i++) { |
|
const val = minVal + (range * i) / 5; |
|
const y = toY(val); |
|
this._svg.appendChild(this._el('line', { |
|
x1: pad.left, y1: y, x2: pad.left + chartW, y2: y, |
|
stroke: '#e0e0e0', 'stroke-width': 1 |
|
})); |
|
const t = this._el('text', { x: pad.left - 5, y: y + 4, 'text-anchor': 'end', class: 'axis-label' }); |
|
t.textContent = yFmt(val); |
|
this._svg.appendChild(t); |
|
} |
|
|
|
if (this._type === 'bar') { |
|
this._drawBars(keys, pad, chartW, maxPts, toY, height); |
|
} else { |
|
this._drawLines(keys, maxPts, toX, toY, pad, height); |
|
} |
|
|
|
if (hasLegend) this._drawLegend(keys, config, pad); |
|
} |
|
|
|
_drawBars(keys, pad, chartW, maxPts, toY, height) { |
|
const zeroY = toY(0); |
|
const groupW = chartW / maxPts; |
|
const barPad = groupW * 0.1; |
|
const barW = (groupW - barPad * 2) / keys.length; |
|
|
|
keys.forEach((key, si) => { |
|
(this._data[key] || []).forEach((val, di) => { |
|
const x = pad.left + di * groupW + barPad + si * barW; |
|
const y = toY(val); |
|
this._svg.appendChild(this._el('rect', { |
|
x, y: Math.min(y, zeroY), width: barW, height: Math.abs(y - zeroY), series: key |
|
})); |
|
}); |
|
}); |
|
|
|
const step = this._labelStep; |
|
const xFmt = this._getXFormatter(); |
|
for (let i = 0; i < maxPts; i++) { |
|
if (i % step !== 0) continue; |
|
const t = this._el('text', { |
|
x: pad.left + i * groupW + groupW / 2, |
|
y: height - pad.bottom + 18, |
|
'text-anchor': 'middle', class: 'axis-label' |
|
}); |
|
t.textContent = xFmt(i); |
|
this._svg.appendChild(t); |
|
} |
|
} |
|
|
|
_drawLines(keys, maxPts, toX, toY, pad, height) { |
|
keys.forEach(key => { |
|
const vals = this._data[key] || []; |
|
if (!vals.length) return; |
|
const d = vals.map((v, i) => `${i ? 'L' : 'M'}${toX(i)} ${toY(v)}`).join(' '); |
|
this._svg.appendChild(this._el('path', { |
|
d, 'stroke-width': 2, 'stroke-linejoin': 'round', 'stroke-linecap': 'round', series: key |
|
})); |
|
}); |
|
|
|
const step = this._labelStep; |
|
const xFmt = this._getXFormatter(); |
|
for (let i = 0; i < maxPts; i++) { |
|
if (i % step !== 0) continue; |
|
const t = this._el('text', { |
|
x: toX(i), y: height - pad.bottom + 18, |
|
'text-anchor': 'middle', class: 'axis-label' |
|
}); |
|
t.textContent = xFmt(i); |
|
this._svg.appendChild(t); |
|
} |
|
} |
|
|
|
_drawLegend(keys, config, pad) { |
|
let lx = pad.left; |
|
keys.forEach(key => { |
|
const label = config[key] || key; |
|
this._svg.appendChild(this._el('rect', { x: lx, y: 6, width: 12, height: 12, series: key })); |
|
const t = this._el('text', { x: lx + 16, y: 16, class: 'legend-text', series: key }); |
|
t.textContent = label; |
|
this._svg.appendChild(t); |
|
lx += 16 + label.length * 7 + 10; |
|
}); |
|
} |
|
|
|
_el(tag, attrs) { |
|
const el = document.createElementNS('http://www.w3.org/2000/svg', tag); |
|
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v); |
|
return el; |
|
} |
|
} |
|
|
|
customElements.define('ai-chart', AiChart); |
|
|
|
const KEYWORDS = { |
|
javascript: /\b(async|await|break|case|catch|class|const|continue|debugger|default|delete|do|else|export|extends|finally|for|function|if|import|in|instanceof|let|new|of|return|static|super|switch|this|throw|try|typeof|var|void|while|with|yield)\b/g, |
|
csharp: /\b(abstract|as|base|bool|break|byte|case|catch|char|checked|class|const|continue|decimal|default|delegate|do|double|else|enum|event|explicit|extern|false|finally|fixed|float|for|foreach|goto|if|implicit|in|int|interface|internal|is|lock|long|namespace|new|null|object|operator|out|override|params|private|protected|public|readonly|ref|return|sbyte|sealed|short|sizeof|stackalloc|static|string|struct|switch|this|throw|true|try|typeof|uint|ulong|unchecked|unsafe|ushort|using|virtual|void|volatile|while)\b/g, |
|
cpp: /\b(alignas|alignof|and|and_eq|asm|auto|bitand|bitor|bool|break|case|catch|char|char8_t|char16_t|char32_t|class|compl|concept|const|consteval|constexpr|constinit|const_cast|continue|co_await|co_return|co_yield|decltype|default|delete|do|double|dynamic_cast|else|enum|explicit|export|extern|false|float|for|friend|goto|if|inline|int|long|mutable|namespace|new|noexcept|not|not_eq|nullptr|operator|or|or_eq|private|protected|public|register|reinterpret_cast|requires|return|short|signed|sizeof|static|static_assert|static_cast|struct|switch|template|this|thread_local|throw|true|try|typedef|typeid|typename|union|unsigned|using|virtual|void|volatile|wchar_t|while|xor|xor_eq)\b/g, |
|
python: /\b(False|None|True|and|as|assert|async|await|break|class|continue|def|del|elif|else|except|finally|for|from|global|if|import|in|is|lambda|nonlocal|not|or|pass|raise|return|try|while|with|yield)\b/g, |
|
}; |
|
|
|
const COMMENT_PATTERNS = { |
|
javascript: /(\/\/[^\n]*|\/\*[\s\S]*?\*\/)/g, |
|
csharp: /(\/\/[^\n]*|\/\*[\s\S]*?\*\/)/g, |
|
cpp: /(\/\/[^\n]*|\/\*[\s\S]*?\*\/)/g, |
|
python: /(#[^\n]*)/g, |
|
}; |
|
|
|
const STRING_PATTERNS = { |
|
javascript: /(`[\s\S]*?`|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')/g, |
|
csharp: /(@"(?:[^"]|"")*"|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')/g, |
|
cpp: /("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')/g, |
|
python: /("""[\s\S]*?"""|'''[\s\S]*?'''|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')/g, |
|
}; |
|
|
|
const NUMBER_PATTERN = /(?<![.\w])\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?[fFdDlLuU]*\b/g; |
|
|
|
function escapeHtml(text) { |
|
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); |
|
} |
|
|
|
function collectRanges(code, pattern, type) { |
|
const ranges = []; |
|
pattern.lastIndex = 0; |
|
let m; |
|
while ((m = pattern.exec(code)) !== null) { |
|
ranges.push({ start: m.index, end: m.index + m[0].length, type, text: m[0] }); |
|
} |
|
return ranges; |
|
} |
|
|
|
function removeOverlaps(ranges) { |
|
ranges.sort((a, b) => a.start - b.start); |
|
const result = []; |
|
let lastEnd = 0; |
|
for (const r of ranges) { |
|
if (r.start >= lastEnd) { |
|
result.push(r); |
|
lastEnd = r.end; |
|
} |
|
} |
|
return result; |
|
} |
|
|
|
function highlightSegment(text, lang) { |
|
const kwPattern = new RegExp(KEYWORDS[lang].source, 'g'); |
|
const ranges = removeOverlaps([ |
|
...collectRanges(text, kwPattern, 'keyword'), |
|
...collectRanges(text, new RegExp(NUMBER_PATTERN.source, 'g'), 'literal'), |
|
]); |
|
let result = ''; |
|
let pos = 0; |
|
for (const r of ranges) { |
|
if (r.start > pos) result += escapeHtml(text.slice(pos, r.start)); |
|
result += `<span class="${r.type}">${escapeHtml(r.text)}</span>`; |
|
pos = r.end; |
|
} |
|
if (pos < text.length) result += escapeHtml(text.slice(pos)); |
|
return result; |
|
} |
|
|
|
function highlight(code, language) { |
|
const lang = language && KEYWORDS[language] ? language : null; |
|
if (!lang) return escapeHtml(code); |
|
|
|
const ranges = removeOverlaps([ |
|
...collectRanges(code, new RegExp(COMMENT_PATTERNS[lang].source, 'g'), 'comment'), |
|
...collectRanges(code, new RegExp(STRING_PATTERNS[lang].source, 'g'), 'literal'), |
|
]); |
|
|
|
let result = ''; |
|
let pos = 0; |
|
for (const r of ranges) { |
|
if (r.start > pos) result += highlightSegment(code.slice(pos, r.start), lang); |
|
result += `<span class="${r.type}">${escapeHtml(r.text)}</span>`; |
|
pos = r.end; |
|
} |
|
if (pos < code.length) result += highlightSegment(code.slice(pos), lang); |
|
return result; |
|
} |
|
|
|
const css = ` |
|
:host { |
|
display: block; |
|
position: relative; |
|
} |
|
pre { |
|
margin: 0; |
|
padding: 1em; |
|
overflow-x: auto; |
|
font-family: var(--code-font, 'Courier New', Courier, monospace); |
|
font-size: 0.9em; |
|
white-space: pre; |
|
background: var(--code-background, #1e1e1e); |
|
color: var(--code-color, #d4d4d4); |
|
border-radius: var(--code-border-radius, 4px); |
|
} |
|
.keyword { color: var(--keyword-color, #569cd6); } |
|
.literal { color: var(--literal-color, #ce9178); } |
|
.comment { color: var(--comment-color, #6a9955); font-style: italic; } |
|
.variable { color: var(--variable-color, #9cdcfe); } |
|
.copy-btn { |
|
position: absolute; |
|
top: 0.5em; |
|
right: 0.5em; |
|
padding: 0.25em 0.6em; |
|
font-size: 0.75em; |
|
cursor: pointer; |
|
background: var(--copy-btn-background, rgba(80,80,80,0.85)); |
|
color: var(--copy-btn-color, #ccc); |
|
border: 1px solid var(--copy-btn-border-color, #555); |
|
border-radius: 3px; |
|
opacity: 0.7; |
|
transition: opacity 0.2s; |
|
} |
|
.copy-btn:hover { opacity: 1; } |
|
`; |
|
|
|
class AiCodeSample extends HTMLElement { |
|
static get observedAttributes() { |
|
return ['language', 'oncopy']; |
|
} |
|
|
|
constructor() { |
|
super(); |
|
this.attachShadow({ mode: 'open' }); |
|
this._copyBtn = null; |
|
} |
|
|
|
connectedCallback() { |
|
this._render(); |
|
} |
|
|
|
attributeChangedCallback() { |
|
if (this.shadowRoot.querySelector('pre')) this._render(); |
|
} |
|
|
|
_render() { |
|
const code = this.textContent; |
|
const language = this.getAttribute('language') || ''; |
|
const onCopyAttr = this.getAttribute('oncopy'); |
|
|
|
let pre = this.shadowRoot.querySelector('pre'); |
|
if (!pre) { |
|
const style = document.createElement('style'); |
|
style.textContent = css; |
|
pre = document.createElement('pre'); |
|
this.shadowRoot.appendChild(style); |
|
this.shadowRoot.appendChild(pre); |
|
} |
|
|
|
pre.innerHTML = highlight(code, language); |
|
|
|
if (this._copyBtn) { |
|
this._copyBtn.remove(); |
|
this._copyBtn = null; |
|
} |
|
|
|
if (onCopyAttr) { |
|
const btn = document.createElement('button'); |
|
btn.className = 'copy-btn'; |
|
btn.type = 'button'; |
|
btn.textContent = 'copy code'; |
|
btn.addEventListener('click', () => { |
|
if (typeof window[onCopyAttr] === 'function') window[onCopyAttr](code); |
|
}); |
|
this.shadowRoot.appendChild(btn); |
|
this._copyBtn = btn; |
|
} |
|
} |
|
} |
|
|
|
customElements.define('ai-codesample', AiCodeSample); |
|
|
|
class AiMenu extends HTMLElement { |
|
static get observedAttributes() { |
|
return ['orientation', 'label']; |
|
} |
|
|
|
constructor() { |
|
super(); |
|
this.attachShadow({ mode: 'open' }); |
|
this.shadowRoot.innerHTML = ` |
|
<style> |
|
:host { |
|
display: block; |
|
} |
|
nav { |
|
display: flex; |
|
flex-direction: column; |
|
list-style: none; |
|
margin: 0; |
|
padding: 0; |
|
} |
|
:host([orientation="horizontal"]) nav { |
|
flex-direction: row; |
|
} |
|
</style> |
|
<nav role="menu" part="menu"> |
|
<slot></slot> |
|
</nav> |
|
`; |
|
} |
|
|
|
connectedCallback() { |
|
const label = this.getAttribute('label'); |
|
if (label) { |
|
this.shadowRoot.querySelector('nav').setAttribute('aria-label', label); |
|
} |
|
} |
|
|
|
attributeChangedCallback(name, oldValue, newValue) { |
|
if (oldValue === newValue) return; |
|
if (name === 'label') { |
|
this.shadowRoot.querySelector('nav').setAttribute('aria-label', newValue ?? ''); |
|
} |
|
} |
|
|
|
get orientation() { |
|
return this.getAttribute('orientation'); |
|
} |
|
|
|
set orientation(value) { |
|
if (value) { |
|
this.setAttribute('orientation', value); |
|
} else { |
|
this.removeAttribute('orientation'); |
|
} |
|
} |
|
|
|
get label() { |
|
return this.getAttribute('label'); |
|
} |
|
|
|
set label(value) { |
|
if (value) { |
|
this.setAttribute('label', value); |
|
} else { |
|
this.removeAttribute('label'); |
|
} |
|
} |
|
} |
|
|
|
customElements.define('ai-menu', AiMenu); |
|
|
|
const sheet = new CSSStyleSheet(); |
|
sheet.replaceSync(/*css*/` |
|
:host { |
|
display: block; |
|
} |
|
a { |
|
display: flex; |
|
align-items: center; |
|
gap: 0.5rem; |
|
padding: 0.5rem 1rem; |
|
text-decoration: none; |
|
color: inherit; |
|
border-radius: 0.25rem; |
|
transition: background-color 0.2s; |
|
} |
|
a:hover { |
|
background-color: rgba(0, 0, 0, 0.05); |
|
} |
|
`); |
|
|
|
class MenuItem extends HTMLElement { |
|
static observedAttributes = ['href', 'label']; |
|
|
|
constructor() { |
|
super(); |
|
this.attachShadow({ mode: 'open' }); |
|
this.shadowRoot.adoptedStyleSheets = [sheet]; |
|
} |
|
|
|
connectedCallback() { |
|
this.render(); |
|
} |
|
|
|
attributeChangedCallback() { |
|
this.render(); |
|
} |
|
|
|
render() { |
|
const href = this.getAttribute('href') || '#'; |
|
const label = this.getAttribute('label') || ''; |
|
this.shadowRoot.innerHTML = `<a href="${href}">${label}</a>`; |
|
} |
|
} |
|
|
|
customElements.define('ai-menuitem', MenuItem); |
|
|
|
const template = document.createElement('template'); |
|
template.innerHTML = ` |
|
<style> |
|
:host { |
|
display: flex; |
|
flex-direction: row; |
|
} |
|
</style> |
|
<slot></slot> |
|
`; |
|
|
|
class RowLayout extends HTMLElement { |
|
constructor() { |
|
super(); |
|
this.attachShadow({ mode: 'open' }); |
|
this.shadowRoot.appendChild(template.content.cloneNode(true)); |
|
} |
|
} |
|
|
|
customElements.define('ai-rowlayout', RowLayout); |
|
|
|
class AiRowLayoutItem extends HTMLElement { |
|
static get observedAttributes() { |
|
return ['role', 'width']; |
|
} |
|
|
|
constructor() { |
|
super(); |
|
this.attachShadow({ mode: 'open' }); |
|
this.shadowRoot.innerHTML = `<style>:host{display:block;box-sizing:border-box;}</style><slot></slot>`; |
|
} |
|
|
|
connectedCallback() { |
|
this.#applyWidth(); |
|
} |
|
|
|
attributeChangedCallback(name) { |
|
if (name === 'width') { |
|
this.#applyWidth(); |
|
} |
|
} |
|
|
|
#applyWidth() { |
|
const width = this.getAttribute('width'); |
|
if (width) { |
|
this.style.width = width; |
|
this.style.flexShrink = width === 'auto' ? '1' : '0'; |
|
this.style.flexGrow = width === 'auto' ? '1' : '0'; |
|
} else { |
|
this.style.width = ''; |
|
this.style.flexShrink = ''; |
|
this.style.flexGrow = ''; |
|
} |
|
} |
|
} |
|
|
|
customElements.define('ai-rowlayoutitem', AiRowLayoutItem); |
|
|
|
export { AiRowLayoutItem, RowLayout, AiMenu, MenuItem, AiCodeSample, AiChart }; |