diff --git a/flightscore/src/components/circle-chart.ts b/flightscore/src/components/circle-chart.ts
new file mode 100644
index 0000000..527daa9
--- /dev/null
+++ b/flightscore/src/components/circle-chart.ts
@@ -0,0 +1,214 @@
+import { LitElement, html, css, svg } from 'lit';
+import { customElement, property } from 'lit/decorators.js';
+
+export interface CircleSegment {
+ label: string;
+ value: number;
+ color?: string;
+}
+
+@customElement('circle-chart')
+export class CircleChart extends LitElement {
+ static styles = css`
+ :host {
+ display: block;
+ }
+
+ .chart-wrapper {
+ background: var(--color-bg-nav);
+ border: 1px solid var(--color-border);
+ border-radius: 0.75rem;
+ padding: 1.75rem;
+ display: flex;
+ flex-direction: column;
+ gap: 1.5rem;
+ transition: border-color 0.25s ease;
+ }
+
+ .chart-wrapper:hover {
+ border-color: color-mix(
+ in srgb,
+ var(--color-accent) 40%,
+ transparent
+ );
+ }
+
+ .title {
+ font-size: 1rem;
+ font-weight: 650;
+ color: var(--color-text);
+ margin: 0;
+ }
+
+ .chart-body {
+ display: flex;
+ align-items: center;
+ gap: 2rem;
+ flex-wrap: wrap;
+ justify-content: center;
+ }
+
+ .svg-container {
+ position: relative;
+ width: 160px;
+ height: 160px;
+ flex-shrink: 0;
+ }
+
+ .center-label {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ pointer-events: none;
+ }
+
+ .center-value {
+ font-size: 1.5rem;
+ font-weight: 800;
+ letter-spacing: -0.03em;
+ color: var(--color-text);
+ }
+
+ .center-text {
+ font-size: 0.72rem;
+ font-weight: 500;
+ color: color-mix(in srgb, var(--color-text) 45%, transparent);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ }
+
+ svg {
+ transform: rotate(-90deg);
+ width: 100%;
+ height: 100%;
+ }
+
+ circle {
+ transition:
+ stroke-dasharray 0.6s cubic-bezier(0.22, 1, 0.36, 1),
+ opacity 0.25s ease;
+ cursor: pointer;
+ }
+
+ circle:hover {
+ opacity: 0.8;
+ filter: brightness(1.15);
+ }
+
+ .legend {
+ display: flex;
+ flex-direction: column;
+ gap: 0.6rem;
+ min-width: 120px;
+ }
+
+ .legend-item {
+ display: flex;
+ align-items: center;
+ gap: 0.55rem;
+ cursor: default;
+ }
+
+ .legend-dot {
+ width: 0.55rem;
+ height: 0.55rem;
+ border-radius: 50%;
+ flex-shrink: 0;
+ }
+
+ .legend-label {
+ font-size: 0.82rem;
+ color: color-mix(in srgb, var(--color-text) 65%, transparent);
+ flex: 1;
+ }
+
+ .legend-value {
+ font-size: 0.82rem;
+ font-weight: 700;
+ color: var(--color-text);
+ font-variant-numeric: tabular-nums;
+ }
+ `;
+
+ @property() heading = '';
+ @property() centerText = 'Total';
+ @property({ type: Array }) segments: CircleSegment[] = [];
+
+ private defaultColors = [
+ 'var(--color-accent)',
+ '#30a46c',
+ '#e79d13',
+ '#e5484d',
+ '#6e56cf',
+ '#0091ff',
+ ];
+
+ render() {
+ const total = this.segments.reduce((s, d) => s + d.value, 0);
+ const radius = 62;
+ const circumference = 2 * Math.PI * radius;
+ const gapAngle = 0.02;
+ const totalGap = gapAngle * this.segments.length;
+ const usable = Math.max(0, circumference * (1 - totalGap));
+ const gapSize = circumference * gapAngle;
+ let offset = 0;
+
+ const arcs = this.segments.map((seg, i) => {
+ const pct = total > 0 ? seg.value / total : 0;
+ const dash = pct * usable;
+ const gap = circumference - dash;
+ const color =
+ seg.color || this.defaultColors[i % this.defaultColors.length];
+ const currentOffset = offset;
+ offset += dash + gapSize;
+
+ return svg`
+
+ `;
+ });
+
+ return html`
+
+ ${this.heading
+ ? html`
${this.heading}
`
+ : null}
+
+
+
+
+ ${total}
+ ${this.centerText}
+
+
+
+ ${this.segments.map(
+ (seg, i) => html`
+
+
+ ${seg.label}
+ ${seg.value}
+
+ `
+ )}
+
+
+
+ `;
+ }
+}
\ No newline at end of file
diff --git a/flightscore/src/components/line-chart.ts b/flightscore/src/components/line-chart.ts
new file mode 100644
index 0000000..b7286cd
--- /dev/null
+++ b/flightscore/src/components/line-chart.ts
@@ -0,0 +1,272 @@
+import { LitElement, html, css, svg } from 'lit';
+import { customElement, property } from 'lit/decorators.js';
+
+export interface LineSeries {
+ label: string;
+ color?: string;
+ points: { x: number; y: number }[];
+}
+
+@customElement('line-chart')
+export class LineChart extends LitElement {
+ static styles = css`
+:host {
+ display: inline-flex;
+ }
+
+ .chart-wrapper {
+ background: var(--color-bg-nav);
+ border: 1px solid var(--color-border);
+ border-radius: 0.75rem;
+ padding: 1.75rem;
+ display: flex;
+ flex-direction: column;
+ gap: 1.25rem;
+ transition: border-color 0.25s ease;
+ }
+
+ .chart-wrapper:hover {
+ border-color: color-mix(
+ in srgb,
+ var(--color-accent) 40%,
+ transparent
+ );
+ }
+
+ .header {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ }
+
+ .title {
+ font-size: 1rem;
+ font-weight: 650;
+ color: var(--color-text);
+ margin: 0;
+ }
+
+ .subtitle {
+ font-size: 0.78rem;
+ color: color-mix(in srgb, var(--color-text) 45%, transparent);
+ margin: 0;
+ }
+
+.svg-container {
+ width: 100%;
+ overflow: visible;
+ aspect-ratio: 500 / 300;
+ }
+
+ svg {
+ width: 100%;
+ height: 100%;
+ overflow: visible;
+ display: block;
+ }
+ .grid-line {
+ stroke: var(--color-border);
+ stroke-width: 0.5;
+ }
+
+ .axis-label {
+ font-size: 9px;
+ fill: color-mix(in srgb, var(--color-text) 40%, transparent);
+ font-weight: 500;
+ font-family: inherit;
+ }
+
+ .line-path {
+ fill: none;
+ stroke-width: 2;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ transition: opacity 0.25s ease;
+ }
+
+ .line-path:hover {
+ opacity: 0.7;
+ }
+
+ .area-path {
+ opacity: 0.08;
+ transition: opacity 0.25s ease;
+ }
+
+ .legend {
+ display: flex;
+ gap: 1rem;
+ flex-wrap: wrap;
+ }
+
+ .legend-item {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ }
+
+ .legend-line {
+ width: 1rem;
+ height: 2px;
+ border-radius: 1px;
+ flex-shrink: 0;
+ }
+
+ .legend-label {
+ font-size: 0.78rem;
+ color: color-mix(in srgb, var(--color-text) 60%, transparent);
+ font-weight: 500;
+ }
+ `;
+
+ @property() heading = '';
+ @property() subtitle = '';
+ @property() xLabel = 'X';
+ @property() yLabel = 'Y';
+ @property({ type: Array }) series: LineSeries[] = [];
+ @property({ type: Boolean }) showArea = false;
+
+ private defaultColors = [
+ 'var(--color-accent)',
+ '#30a46c',
+ '#e79d13',
+ '#e5484d',
+ '#6e56cf',
+ '#0091ff',
+ ];
+
+ private padding = { top: 20, right: 25, bottom: 40, left: 45 };
+ private width = 500;
+ private height = 300;
+
+ private niceScale(min: number, max: number, ticks: number) {
+ const range = max - min || 1;
+ const rough = range / ticks;
+ const mag = Math.pow(10, Math.floor(Math.log10(rough)));
+ const norm = rough / mag;
+ let step: number;
+ if (norm <= 1.5) step = 1 * mag;
+ else if (norm <= 3) step = 2 * mag;
+ else if (norm <= 7) step = 5 * mag;
+ else step = 10 * mag;
+ const nMin = Math.floor(min / step) * step;
+ const nMax = Math.ceil(max / step) * step;
+ return { min: nMin, max: nMax, step };
+ }
+
+ render() {
+ const p = this.padding;
+ const w = this.width - p.left - p.right;
+ const h = this.height - p.top - p.bottom;
+
+ const allPts = this.series.flatMap((s) => s.points);
+ const xs = allPts.map((d) => d.x);
+ const ys = allPts.map((d) => d.y);
+ const rawXMin = Math.min(...xs, 0);
+ const rawXMax = Math.max(...xs, 1);
+ const rawYMin = Math.min(...ys, 0);
+ const rawYMax = Math.max(...ys, 1);
+
+ const xScale = this.niceScale(rawXMin, rawXMax, 5);
+ const yScale = this.niceScale(rawYMin, rawYMax, 5);
+
+ const sx = (v: number) =>
+ p.left + ((v - xScale.min) / (xScale.max - xScale.min)) * w;
+ const sy = (v: number) =>
+ p.top +
+ h -
+ ((v - yScale.min) / (yScale.max - yScale.min)) * h;
+
+ const gridLines = [];
+ for (
+ let val = yScale.min;
+ val <= yScale.max + yScale.step * 0.01;
+ val += yScale.step
+ ) {
+ const y = sy(val);
+ gridLines.push(svg`
+
+ ${Math.round(val)}
+ `);
+ }
+ for (
+ let val = xScale.min;
+ val <= xScale.max + xScale.step * 0.01;
+ val += xScale.step
+ ) {
+ const x = sx(val);
+ gridLines.push(svg`
+ ${Math.round(val)}
+ `);
+ }
+
+ const baseLine = sy(yScale.min);
+
+ const lines = this.series.map((s, i) => {
+ const color =
+ s.color || this.defaultColors[i % this.defaultColors.length];
+ const sorted = [...s.points].sort((a, b) => a.x - b.x);
+ if (!sorted.length) return svg``;
+
+ const d = sorted
+ .map((pt, j) => `${j === 0 ? 'M' : 'L'}${sx(pt.x)},${sy(pt.y)}`)
+ .join(' ');
+
+ const areaD =
+ `M${sx(sorted[0].x)},${baseLine} ` +
+ sorted.map((pt) => `L${sx(pt.x)},${sy(pt.y)}`).join(' ') +
+ ` L${sx(sorted[sorted.length - 1].x)},${baseLine} Z`;
+
+ return svg`
+ ${this.showArea ? svg`` : null}
+
+ `;
+ });
+
+ return html`
+
+ ${this.heading || this.subtitle
+ ? html`
+
+ `
+ : null}
+
+
+
+ ${this.series.length > 1
+ ? html`
+
+ ${this.series.map(
+ (s, i) => html`
+
+
+ ${s.label}
+
+ `
+ )}
+
+ `
+ : null}
+
+ `;
+ }
+}
\ No newline at end of file
diff --git a/flightscore/src/components/loading-bar.ts b/flightscore/src/components/loading-bar.ts
new file mode 100644
index 0000000..8c3dfda
--- /dev/null
+++ b/flightscore/src/components/loading-bar.ts
@@ -0,0 +1,139 @@
+// components/loading-bar.ts
+import { LitElement, html, css } from 'lit';
+import { customElement, property } from 'lit/decorators.js';
+
+@customElement('loading-bar')
+export class LoadingBar extends LitElement {
+ static styles = css`
+ :host {
+ display: block;
+ width: 100%;
+ }
+
+ .wrapper {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ }
+
+ .meta {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ }
+
+ .label {
+ font-size: 0.8rem;
+ font-weight: 600;
+ letter-spacing: 0.02em;
+ color: color-mix(in srgb, var(--color-text) 70%, transparent);
+ text-transform: uppercase;
+ }
+
+ .value {
+ font-size: 0.85rem;
+ font-weight: 700;
+ color: var(--color-text);
+ font-variant-numeric: tabular-nums;
+ }
+
+ .track {
+ width: 100%;
+ height: 0.5rem;
+ background: color-mix(in srgb, var(--color-text) 8%, transparent);
+ border-radius: 1rem;
+ overflow: hidden;
+ position: relative;
+ }
+
+ :host([size='sm']) .track {
+ height: 0.3rem;
+ }
+
+ :host([size='lg']) .track {
+ height: 0.75rem;
+ }
+
+ .fill {
+ height: 100%;
+ border-radius: 1rem;
+ background: var(--color-accent);
+ transition: width 0.6s cubic-bezier(0.22, 1, 0.36, 1);
+ position: relative;
+ }
+
+ .fill.success {
+ background: #30a46c;
+ }
+
+ .fill.warning {
+ background: #e79d13;
+ }
+
+ .fill.error {
+ background: #e5484d;
+ }
+
+ :host([indeterminate]) .fill {
+ width: 40% !important;
+ animation: indeterminate 1.4s ease-in-out infinite;
+ }
+
+ @keyframes indeterminate {
+ 0% {
+ transform: translateX(-100%);
+ }
+ 100% {
+ transform: translateX(350%);
+ }
+ }
+
+ .fill::after {
+ content: '';
+ position: absolute;
+ inset: 0;
+ background: linear-gradient(
+ 90deg,
+ transparent 0%,
+ rgba(255, 255, 255, 0.15) 50%,
+ transparent 100%
+ );
+ border-radius: inherit;
+ }
+ `;
+
+ @property({ type: Number }) value = 0;
+ @property() label = '';
+ @property() variant: 'accent' | 'success' | 'warning' | 'error' =
+ 'accent';
+ @property({ type: Boolean, reflect: true }) indeterminate = false;
+ @property({ type: Boolean }) hideValue = false;
+ @property({ reflect: true }) size: 'sm' | 'md' | 'lg' = 'md';
+
+ render() {
+ const clamped = Math.max(0, Math.min(100, this.value));
+
+ return html`
+
+ ${this.label || !this.hideValue
+ ? html`
+
+ ${this.label
+ ? html`${this.label}`
+ : html``}
+ ${!this.hideValue && !this.indeterminate
+ ? html`${clamped}%`
+ : null}
+
+ `
+ : null}
+
+
+ `;
+ }
+}
\ No newline at end of file
diff --git a/flightscore/src/components/ui-badge.ts b/flightscore/src/components/ui-badge.ts
index a6ab879..f2e9d38 100644
--- a/flightscore/src/components/ui-badge.ts
+++ b/flightscore/src/components/ui-badge.ts
@@ -1,6 +1,6 @@
// components/ui-badge.ts
import { LitElement, html, css } from 'lit';
-import { customElement, property } from 'lit/decorators.js';
+import { customElement, property, state } from 'lit/decorators.js';
export type BadgeVariant = 'accent' | 'success' | 'warning' | 'error' | 'muted';
@@ -11,30 +11,37 @@ export class UiBadge extends LitElement {
display: inline-flex;
}
- .badge {
- display: inline-flex;
- align-items: center;
- gap: 0.4rem;
- padding: 0.3rem 0.75rem;
- font-size: 0.75rem;
- font-weight: 600;
- letter-spacing: 0.03em;
- text-transform: uppercase;
- border-radius: 2rem;
- border: 1px solid transparent;
- white-space: nowrap;
- }
+.badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+ padding: 0.35rem 0.75rem;
+ font-size: 0.75rem;
+ font-weight: 600;
+ letter-spacing: 0.03em;
+ text-transform: uppercase;
+ border-radius: 2rem;
+ border: 1px solid transparent;
+ white-space: nowrap;
+ line-height: 1;
+}
- .icon {
- display: inline-flex;
- align-items: center;
- }
+.icon {
+ display: inline-flex;
+ align-items: center;
+ flex-shrink: 0;
+}
- .icon ::slotted(svg) {
- width: 0.85rem;
- height: 0.85rem;
- flex-shrink: 0;
- }
+.icon ::slotted(svg) {
+ width: 0.85rem;
+ height: 0.85rem;
+}
+
+.label {
+ display: inline-flex;
+ align-items: center;
+ padding-top: 0.05em;
+}
.badge.accent {
color: var(--color-accent);
@@ -68,13 +75,25 @@ export class UiBadge extends LitElement {
`;
@property() variant: BadgeVariant = 'accent';
+ @state() private hasIcon = false;
+
+ private handleSlotChange(e: Event) {
+ const slot = e.target as HTMLSlotElement;
+ this.hasIcon = slot.assignedNodes().length > 0;
+ }
render() {
return html`
-
-
+
+ ${this.hasIcon
+ ? html`
+
+ `
+ : html``}
+
- `;
+
+ `;
}
}
\ No newline at end of file
diff --git a/flightscore/src/components/ui-button-secondary.ts b/flightscore/src/components/ui-button-secondary.ts
index 894a442..c543156 100644
--- a/flightscore/src/components/ui-button-secondary.ts
+++ b/flightscore/src/components/ui-button-secondary.ts
@@ -1,6 +1,6 @@
// components/ui-button-secondary.ts
import { LitElement, html, css } from 'lit';
-import { customElement, property } from 'lit/decorators.js';
+import { customElement, property, state } from 'lit/decorators.js';
@customElement('ui-button-secondary')
export class UiButtonSecondary extends LitElement {
@@ -51,11 +51,15 @@ export class UiButtonSecondary extends LitElement {
opacity: 0.55;
cursor: not-allowed;
}
+
+.icon {
+ display: inline-flex;
+ align-items: center;
+}
- .icon {
- display: inline-flex;
- align-items: center;
- }
+.icon:not(:has(*)) {
+ display: none;
+}
.icon ::slotted(svg) {
width: 1rem;
@@ -67,12 +71,21 @@ export class UiButtonSecondary extends LitElement {
@property({ type: Boolean }) disabled = false;
@property({ type: Boolean, reflect: true }) full = false;
+ @state() private hasIcon = false;
+
+ private handleSlotChange(e: Event) {
+ const slot = e.target as HTMLSlotElement;
+ this.hasIcon = slot.assignedNodes().length > 0;
+ }
+
render() {
return html`
-
+
`;
}
}
\ No newline at end of file
diff --git a/flightscore/src/components/ui-button.ts b/flightscore/src/components/ui-button.ts
index bb6a255..919b08f 100644
--- a/flightscore/src/components/ui-button.ts
+++ b/flightscore/src/components/ui-button.ts
@@ -1,6 +1,6 @@
// components/ui-button.ts
import { LitElement, html, css } from 'lit';
-import { customElement, property } from 'lit/decorators.js';
+import { customElement, property, state } from 'lit/decorators.js';
@customElement('ui-button')
export class UiButton extends LitElement {
@@ -52,11 +52,14 @@ export class UiButton extends LitElement {
opacity: 0.55;
cursor: not-allowed;
}
+.icon {
+ display: inline-flex;
+ align-items: center;
+}
- .icon {
- display: inline-flex;
- align-items: center;
- }
+.icon:not(:has(*)) {
+ display: none;
+}
.icon ::slotted(svg) {
width: 1rem;
@@ -68,12 +71,20 @@ export class UiButton extends LitElement {
@property({ type: Boolean }) disabled = false;
@property({ type: Boolean, reflect: true }) full = false;
+ @state() private hasIcon = false;
+
+ private handleSlotChange(e: Event) {
+ const slot = e.target as HTMLSlotElement;
+ this.hasIcon = slot.assignedNodes().length > 0;
+ }
render() {
return html`
-
+
`;
}
}
\ No newline at end of file
diff --git a/flightscore/src/pages/dev-page.ts b/flightscore/src/pages/dev-page.ts
index 7fff3cf..30927c8 100644
--- a/flightscore/src/pages/dev-page.ts
+++ b/flightscore/src/pages/dev-page.ts
@@ -13,6 +13,9 @@ import '../components/form-input';
import '../components/horizontal-divider';
import '../components/notify-bar';
import '../components/ui-link';
+import '../components/circle-chart';
+import '../components/loading-bar';
+import '../components/line-chart';
@customElement('dev-page')
export class DevPage extends LitElement {
@@ -571,6 +574,102 @@ export class DevPage extends LitElement {
+
+
+
+
Loading Bars
+
Progress indicators with variants and states
+
+
+
Variants
+
+
+
+
+
+
+
Sizes
+
+
+
+
+
+
Indeterminate
+
+
+
+
+
+
+
+
+
+
Line Chart
+
Trend lines with optional area fill
+
+
+
+
+
+
+
+
Circle Chart
+
Donut chart for proportional data
+
+
+
`;
}