Created
November 4, 2024 22:39
-
-
Save sebinsua/86c4a1a71cfa9adec62d7156722f5b5b to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React, { useEffect, useRef, useState } from 'react'; | |
class VolumeGenerator { | |
public name: string; | |
private volume: number; | |
private delta: number; | |
constructor(name: string, initialVolume = 50) { | |
this.name = name; | |
this.volume = initialVolume; | |
this.delta = 0; | |
} | |
read() { | |
this.volume = Math.min(100, Math.max(0, this.volume + (Math.random() - 0.5) * 20)); | |
this.delta = Math.min(1, Math.max(-1, this.delta + (Math.random() - 0.5) * 0.2)); | |
return { volume: this.volume, delta: this.delta }; | |
} | |
} | |
function getColor(delta: number): string { | |
const hue = (delta + 1) * 60; // Maps -1->0 and 1->120 (red through yellow to green) | |
return `hsl(${hue}, 80%, 50%)`; | |
} | |
// Get contrasting text color based on background hue | |
// For our scale: red(0°) -> yellow(60°) -> green(120°) | |
function getContrastingColor(delta: number): string { | |
const hue = (delta + 1) * 60; | |
// Yellow-ish hues (around 60°) need dark text | |
// We can extend this a bit on either side for yellow-green and orange | |
const needsDarkText = hue > 30 && hue < 90; | |
return needsDarkText ? '#000000' : '#ffffff'; | |
} | |
export default function Demo() { | |
const canvasRef = useRef<HTMLCanvasElement>(null); | |
const [cellSize, setCellSize] = useState({ width: 0, height: 0 }); | |
useEffect(() => { | |
const canvas = canvasRef.current; | |
const cell = canvas?.parentElement; | |
if (!canvas || !cell) return; | |
const resizeObserver = new ResizeObserver(() => { | |
const cellRect = cell.getBoundingClientRect(); | |
setCellSize(prev => | |
prev.height !== cellRect.height || prev.width !== cellRect.width | |
? { width: cellRect.width, height: cellRect.height } | |
: prev | |
); | |
}); | |
resizeObserver.observe(cell); | |
return () => resizeObserver.disconnect(); | |
}, []); | |
useEffect(() => { | |
const canvas = canvasRef.current; | |
if (!canvas) return; | |
const ctx = canvas.getContext('2d'); | |
if (!ctx) return; | |
const PADDING = 4; | |
const SAMPLE_INTERVAL = 50; | |
const fontSize = Math.round(cellSize.height * 0.67); | |
const TEXT_FONT = `${fontSize}px monospace`; | |
const dataGenerator = new VolumeGenerator('volume'); | |
let rafId: number; | |
let lastSampleTime = performance.now(); | |
let previousValue = { volume: 0, delta: 0 }; | |
function draw() { | |
const now = performance.now(); | |
const deltaTime = now - lastSampleTime; | |
if (deltaTime >= SAMPLE_INTERVAL) { | |
const newValue = dataGenerator.read(); | |
// Clear canvas | |
ctx.clearRect(0, 0, cellSize.width, cellSize.height); | |
// Draw background bar | |
ctx.fillStyle = '#f3f4f6'; | |
ctx.fillRect(PADDING, PADDING, cellSize.width - PADDING * 2, cellSize.height - PADDING * 2); | |
// Draw volume bar | |
const barWidth = ((cellSize.width - PADDING * 2) * (newValue.volume / 100)); | |
ctx.fillStyle = getColor(newValue.delta); | |
ctx.fillRect( | |
PADDING, | |
PADDING, | |
barWidth, | |
cellSize.height - PADDING * 2 | |
); | |
// Set up text properties | |
ctx.font = TEXT_FONT; | |
ctx.textAlign = 'center'; | |
ctx.textBaseline = 'middle'; | |
const text = newValue.volume.toFixed(2); | |
const textX = cellSize.width / 2; | |
// Check if text is over the bar | |
const isOverBar = textX < (PADDING + barWidth); | |
// Choose text color based on position and background color | |
ctx.fillStyle = isOverBar ? | |
getContrastingColor(newValue.delta) : | |
'#000000'; | |
// Only add shadow for light text on dark backgrounds | |
if (isOverBar && ctx.fillStyle === '#ffffff') { | |
ctx.shadowColor = 'rgba(0, 0, 0, 0.4)'; | |
ctx.shadowBlur = 3; | |
} else { | |
ctx.shadowColor = 'transparent'; | |
ctx.shadowBlur = 0; | |
} | |
ctx.fillText( | |
text, | |
textX, | |
cellSize.height / 2 | |
); | |
previousValue = newValue; | |
lastSampleTime = now; | |
} | |
rafId = requestAnimationFrame(draw); | |
} | |
draw(); | |
return () => { | |
cancelAnimationFrame(rafId); | |
ctx.clearRect(0, 0, cellSize.width, cellSize.height); | |
}; | |
}, [cellSize]); | |
return ( | |
<div className="h-24"> | |
<canvas | |
ref={canvasRef} | |
style={{ display: 'block', verticalAlign: 'top' }} | |
width={cellSize.width} | |
height={cellSize.height} | |
/> | |
</div> | |
); | |
} |
Author
sebinsua
commented
Nov 4, 2024
import React, { useEffect, useRef, useState } from 'react';
interface VolumeBarProps {
mirror?: boolean;
className?: string;
}
function VolumeBar({ mirror = false, className = "" }: VolumeBarProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [cellSize, setCellSize] = useState({ width: 0, height: 0 });
useEffect(() => {
const canvas = canvasRef.current;
const cell = canvas?.parentElement;
if (!canvas || !cell) return;
const resizeObserver = new ResizeObserver(() => {
const cellRect = cell.getBoundingClientRect();
setCellSize(prev =>
prev.height !== cellRect.height || prev.width !== cellRect.width
? { width: cellRect.width, height: cellRect.height }
: prev
);
});
resizeObserver.observe(cell);
return () => resizeObserver.disconnect();
}, []);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const PADDING = 4;
const SAMPLE_INTERVAL = 50;
const fontSize = Math.round(cellSize.height * 0.67);
const TEXT_FONT = `${fontSize}px monospace`;
const dataGenerator = new VolumeGenerator('volume');
let rafId: number;
let lastSampleTime = performance.now();
function draw() {
const now = performance.now();
const deltaTime = now - lastSampleTime;
if (deltaTime >= SAMPLE_INTERVAL) {
const newValue = dataGenerator.read();
ctx.clearRect(0, 0, cellSize.width, cellSize.height);
// Draw background
ctx.fillStyle = '#f3f4f6';
ctx.fillRect(PADDING, PADDING, cellSize.width - PADDING * 2, cellSize.height - PADDING * 2);
// Calculate bar dimensions
const barWidth = ((cellSize.width - PADDING * 2) * (newValue.volume / 100));
// Draw volume bar (from left or right depending on mirror)
ctx.fillStyle = getColor(newValue.delta);
if (mirror) {
ctx.fillRect(
cellSize.width - PADDING - barWidth,
PADDING,
barWidth,
cellSize.height - PADDING * 2
);
} else {
ctx.fillRect(
PADDING,
PADDING,
barWidth,
cellSize.height - PADDING * 2
);
}
// Setup text
ctx.font = TEXT_FONT;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const text = newValue.volume.toFixed(2);
const textX = cellSize.width / 2;
// Check if text is over the bar (accounting for mirror)
const isOverBar = mirror ?
textX > (cellSize.width - PADDING - barWidth) :
textX < (PADDING + barWidth);
// Set text color
ctx.fillStyle = isOverBar ?
getContrastingColor(newValue.delta) :
'#000000';
// Shadow for contrast
if (isOverBar && ctx.fillStyle === '#ffffff') {
ctx.shadowColor = 'rgba(0, 0, 0, 0.4)';
ctx.shadowBlur = 3;
} else {
ctx.shadowColor = 'transparent';
ctx.shadowBlur = 0;
}
ctx.fillText(
text,
textX,
cellSize.height / 2
);
lastSampleTime = now;
}
rafId = requestAnimationFrame(draw);
}
draw();
return () => {
cancelAnimationFrame(rafId);
ctx.clearRect(0, 0, cellSize.width, cellSize.height);
};
}, [cellSize, mirror]);
return (
<canvas
ref={canvasRef}
className={className}
style={{ display: 'block', width: '100%', height: '100%' }}
width={cellSize.width}
height={cellSize.height}
/>
);
}
// Demo showing two bars side by side
export default function Demo() {
return (
<div className="flex gap-1">
<div className="h-24 flex-1">
<VolumeBar mirror={true} />
</div>
<div className="h-24 flex-1">
<VolumeBar />
</div>
</div>
);
}
// Helper functions
class VolumeGenerator {
public name: string;
private volume: number;
private delta: number;
constructor(name: string, initialVolume = 50) {
this.name = name;
this.volume = initialVolume;
this.delta = 0;
}
read() {
this.volume = Math.min(100, Math.max(0, this.volume + (Math.random() - 0.5) * 20));
this.delta = Math.min(1, Math.max(-1, this.delta + (Math.random() - 0.5) * 0.2));
return { volume: this.volume, delta: this.delta };
}
}
function getColor(delta: number): string {
const hue = (delta + 1) * 60;
return `hsl(${hue}, 80%, 50%)`;
}
function getContrastingColor(delta: number): string {
const hue = (delta + 1) * 60;
const needsDarkText = hue > 30 && hue < 90;
return needsDarkText ? '#000000' : '#ffffff';
}
import React, { useEffect, useRef, useState } from 'react';
// Same helper classes...
class VolumeGenerator {
private volume: number;
private delta: number;
constructor(name: string, initialVolume = 50) {
this.volume = initialVolume;
this.delta = 0;
}
read() {
this.volume = Math.min(100, Math.max(0, this.volume + (Math.random() - 0.5) * 20));
this.delta = Math.min(1, Math.max(-1, this.delta + (Math.random() - 0.5) * 0.2));
return { volume: this.volume, delta: this.delta };
}
}
function getColor(delta: number): string {
const hue = (delta + 1) * 60;
return `hsl(${hue}, 80%, 50%)`;
}
function getContrastingColor(delta: number): string {
const hue = (delta + 1) * 60;
const needsDarkText = hue > 30 && hue < 90;
return needsDarkText ? '#000000' : '#ffffff';
}
interface VolumeBarProps {
mirror?: boolean;
showWings?: boolean;
wingDecayRate?: number;
}
function VolumeBar({ mirror = false, showWings = false, wingDecayRate = 0.995 }: VolumeBarProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [cellSize, setCellSize] = useState({ width: 0, height: 0 });
const wingsRef = useRef({
min: 50,
max: 50,
lastValue: 50
});
useEffect(() => {
const canvas = canvasRef.current;
const cell = canvas?.parentElement;
if (!canvas || !cell) return;
const resizeObserver = new ResizeObserver(() => {
const cellRect = cell.getBoundingClientRect();
setCellSize(prev =>
prev.height !== cellRect.height || prev.width !== cellRect.width
? { width: cellRect.width, height: cellRect.height }
: prev
);
});
resizeObserver.observe(cell);
return () => resizeObserver.disconnect();
}, []);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const PADDING = 4;
const SAMPLE_INTERVAL = 50;
const WING_WIDTH = 2;
const fontSize = Math.round(cellSize.height * 0.67);
const TEXT_FONT = `${fontSize}px monospace`;
const dataGenerator = new VolumeGenerator('volume');
let rafId: number;
let lastSampleTime = performance.now();
function updateWings(value: number) {
const wings = wingsRef.current;
if (value < wings.min) wings.min = value;
if (value > wings.max) wings.max = value;
wings.min = wings.min + (value - wings.min) * (1 - wingDecayRate);
wings.max = wings.max + (value - wings.max) * (1 - wingDecayRate);
wings.lastValue = value;
}
function draw() {
const now = performance.now();
const deltaTime = now - lastSampleTime;
if (deltaTime >= SAMPLE_INTERVAL) {
const newValue = dataGenerator.read();
ctx.clearRect(0, 0, cellSize.width, cellSize.height);
// Draw background
ctx.fillStyle = '#f3f4f6';
ctx.fillRect(PADDING, PADDING, cellSize.width - PADDING * 2, cellSize.height - PADDING * 2);
const barWidth = ((cellSize.width - PADDING * 2) * (newValue.volume / 100));
// Draw volume bar
ctx.fillStyle = getColor(newValue.delta);
if (mirror) {
// Right bar: starts from left edge
ctx.fillRect(
PADDING,
PADDING,
barWidth,
cellSize.height - PADDING * 2
);
} else {
// Left bar: starts from right edge
ctx.fillRect(
cellSize.width - PADDING - barWidth,
PADDING,
barWidth,
cellSize.height - PADDING * 2
);
}
// Draw wings if enabled
if (showWings) {
updateWings(newValue.volume);
const wings = wingsRef.current;
const minWidth = ((cellSize.width - PADDING * 2) * (wings.min / 100));
const maxWidth = ((cellSize.width - PADDING * 2) * (wings.max / 100));
ctx.strokeStyle = '#666';
ctx.lineWidth = WING_WIDTH;
if (mirror) {
// Right side wings from left
ctx.beginPath();
ctx.moveTo(PADDING + minWidth, PADDING);
ctx.lineTo(PADDING + minWidth, cellSize.height - PADDING);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(PADDING + maxWidth, PADDING);
ctx.lineTo(PADDING + maxWidth, cellSize.height - PADDING);
ctx.stroke();
} else {
// Left side wings from right
ctx.beginPath();
ctx.moveTo(cellSize.width - PADDING - minWidth, PADDING);
ctx.lineTo(cellSize.width - PADDING - minWidth, cellSize.height - PADDING);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(cellSize.width - PADDING - maxWidth, PADDING);
ctx.lineTo(cellSize.width - PADDING - maxWidth, cellSize.height - PADDING);
ctx.stroke();
}
}
// Setup text
ctx.font = TEXT_FONT;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const text = newValue.volume.toFixed(2);
const textX = cellSize.width / 2;
const isOverBar = mirror ?
textX < PADDING + barWidth :
textX > cellSize.width - PADDING - barWidth;
ctx.fillStyle = isOverBar ?
getContrastingColor(newValue.delta) :
'#000000';
if (isOverBar && ctx.fillStyle === '#ffffff') {
ctx.shadowColor = 'rgba(0, 0, 0, 0.4)';
ctx.shadowBlur = 3;
} else {
ctx.shadowColor = 'transparent';
ctx.shadowBlur = 0;
}
ctx.fillText(
text,
textX,
cellSize.height / 2
);
lastSampleTime = now;
}
rafId = requestAnimationFrame(draw);
}
draw();
return () => cancelAnimationFrame(rafId);
}, [cellSize, mirror, showWings, wingDecayRate]);
return (
<canvas
ref={canvasRef}
style={{ display: 'block', width: '100%', height: '100%' }}
width={cellSize.width}
height={cellSize.height}
/>
);
}
export default function Demo() {
return (
<div className="flex gap-4">
<div className="flex-1 h-24">
<VolumeBar showWings={true} />
</div>
<div className="flex-1 h-24">
<VolumeBar showWings={true} mirror={true} />
</div>
</div>
);
}
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment