Skip to content

Commit a9e2fe7

Browse files
committed
feat: add NeoPixel Matrix element
1 parent c6a50a2 commit a9e2fe7

File tree

3 files changed

+271
-0
lines changed

3 files changed

+271
-0
lines changed

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ export { PushbuttonElement } from './pushbutton-element';
99
export { ResistorElement } from './resistor-element';
1010
export { MembraneKeypadElement } from './membrane-keypad-element';
1111
export { PotentiometerElement } from './potentiometer-element';
12+
export { NeopixelMatrixElement } from './neopixel-matrix-element';
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { withKnobs, number, boolean } from '@storybook/addon-knobs';
2+
import { storiesOf } from '@storybook/web-components';
3+
import { html } from 'lit-html';
4+
import './neopixel-matrix-element';
5+
6+
storiesOf('NeoPixel Matrix', module)
7+
.addParameters({ component: 'wokwi-neopixel-matrix' })
8+
.addDecorator(withKnobs)
9+
.add(
10+
'8x8, green background',
11+
() => html`
12+
<div style="display: inline-block; background: #363; padding: 4px">
13+
<wokwi-neopixel-matrix
14+
rows="${number('rows', 8, { min: 1, max: 32 })}"
15+
cols="${number('cols', 8, { min: 1, max: 32 })}"
16+
.blurLight="${boolean('blurLight', true)}"
17+
.animation="${boolean('animation', true)}"
18+
></wokwi-neopixel-matrix>
19+
</div>
20+
`
21+
)
22+
.add(
23+
'16x16, dark background',
24+
() => html`
25+
<div style="display: inline-block; background: #333; padding: 4px">
26+
<wokwi-neopixel-matrix
27+
rows="16"
28+
cols="16"
29+
.animation="${boolean('animation', true)}"
30+
></wokwi-neopixel-matrix>
31+
</div>
32+
`
33+
);

src/neopixel-matrix-element.ts

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
import { css, customElement, html, LitElement, property, svg } from 'lit-element';
2+
3+
const pixelWidth = 5.66;
4+
const pixelHeight = 5;
5+
6+
export interface RGB {
7+
r: number;
8+
g: number;
9+
b: number;
10+
}
11+
12+
/**
13+
* Renders a matrix of NeoPixels (smart RGB LEDs).
14+
* Optimized for displaying large matrices (up to thousands of elements).
15+
*
16+
* The color of individual pixels can be set by calling `setPixel(row, col, { r, g, b })`
17+
* on this element, e.g. `element.setPixel(0, 0, { r: 1, g: 0, b: 0 })` to set the leftmost
18+
* pixel to red.
19+
*/
20+
@customElement('wokwi-neopixel-matrix')
21+
export class NeopixelMatrixElement extends LitElement {
22+
/**
23+
* Number of rows in the matrix
24+
*/
25+
@property() rows = 8;
26+
27+
/**
28+
* Number of columns in the matrix
29+
*/
30+
@property() cols = 8;
31+
32+
/**
33+
* The spacing between two adjacent rows, in mm
34+
*/
35+
36+
/**
37+
* The spacing between two adjacent columns, in mm
38+
*/
39+
@property({ attribute: 'colspacing' }) colSpacing = 1;
40+
41+
/**
42+
* Whether to apply blur to the light. Blurring the light
43+
* creates a bit more realistic look, but negatively impacts
44+
* performance. It's recommended to leave this off for large
45+
* matrices.
46+
*/
47+
@property() blurLight = false;
48+
49+
/**
50+
* Animate the LEDs in the matrix. Used primarily for testing in Storybook.
51+
* The animation sequence is not guaranteed and may change in future releases of
52+
* this element.
53+
*/
54+
@property() animation = false;
55+
56+
@property({ attribute: 'rowspacing' }) rowSpacing = 1;
57+
58+
private pixelElements: Array<[SVGElement, SVGElement, SVGElement, SVGElement]> | null = null;
59+
60+
private animationFrame: number | null = null;
61+
62+
static get styles() {
63+
return css`
64+
:host {
65+
display: flex;
66+
}
67+
`;
68+
}
69+
70+
private getPixelElements() {
71+
if (!this.shadowRoot) {
72+
return null;
73+
}
74+
if (!this.pixelElements) {
75+
this.pixelElements = Array.from(this.shadowRoot.querySelectorAll('g.pixel')).map(
76+
(e) =>
77+
(Array.from(e.querySelectorAll('ellipse')) as unknown) as [
78+
SVGElement,
79+
SVGElement,
80+
SVGElement,
81+
SVGElement
82+
]
83+
);
84+
}
85+
return this.pixelElements;
86+
}
87+
88+
/**
89+
* Resets all the pixels to off state (r=0, g=0, b=0).
90+
*/
91+
reset() {
92+
const pixelElements = this.getPixelElements();
93+
if (!pixelElements) {
94+
return;
95+
}
96+
97+
for (const [rElement, gElement, bElement, colorElement] of pixelElements) {
98+
rElement.style.opacity = '0';
99+
gElement.style.opacity = '0';
100+
bElement.style.opacity = '0';
101+
colorElement.style.opacity = '0';
102+
}
103+
}
104+
105+
/**
106+
* Sets the color of a single neopixel in the matrix
107+
* @param row Row number of the pixel to set
108+
* @param col Column number of the pixel to set
109+
* @param rgb An object containing the {r, g, b} values for the pixel
110+
*/
111+
setPixel(row: number, col: number, rgb: RGB) {
112+
const pixelElements = this.getPixelElements();
113+
if (row < 0 || col < 0 || row >= this.rows || col >= this.cols || !pixelElements) {
114+
return null;
115+
}
116+
const { r, g, b } = rgb;
117+
const spotOpacity = (value: number) => (value > 0.001 ? 0.7 + value * 0.3 : 0);
118+
const maxOpacity = Math.max(r, g, b);
119+
const minOpacity = Math.min(r, g, b);
120+
const opacityDelta = maxOpacity - minOpacity;
121+
const multiplier = Math.max(1, 2 - opacityDelta * 20);
122+
const glowBase = 0.1 + Math.max(maxOpacity * 2 - opacityDelta * 5, 0);
123+
const glowColor = (value: number) => (value > 0.005 ? 0.1 + value * 0.9 : 0);
124+
const glowOpacity = (value: number) => (value > 0.005 ? glowBase + value * (1 - glowBase) : 0);
125+
const cssVal = (value: number) =>
126+
maxOpacity ? Math.floor(Math.min(glowColor(value / maxOpacity) * multiplier, 1) * 255) : 255;
127+
const cssColor = `rgb(${cssVal(r)}, ${cssVal(g)}, ${cssVal(b)})`;
128+
const pixelElement = pixelElements[row * this.cols + col];
129+
const [rElement, gElement, bElement, colorElement] = pixelElement;
130+
rElement.style.opacity = spotOpacity(r).toFixed(2);
131+
gElement.style.opacity = spotOpacity(g).toFixed(2);
132+
bElement.style.opacity = spotOpacity(b).toFixed(2);
133+
colorElement.style.opacity = glowOpacity(maxOpacity).toFixed(2);
134+
colorElement.style.fill = cssColor;
135+
}
136+
137+
private animateStep = () => {
138+
const time = new Date().getTime();
139+
const { rows, cols } = this;
140+
const pixelValue = (n: number) => (n % 2000 > 1000 ? 1 - (n % 1000) / 1000 : (n % 1000) / 1000);
141+
for (let row = 0; row < rows; row++) {
142+
for (let col = 0; col < cols; col++) {
143+
const radius = Math.sqrt((row - rows / 2 + 0.5) ** 2 + (col - cols / 2 + 0.5) ** 2);
144+
this.setPixel(row, col, {
145+
r: pixelValue(radius * 100 + time),
146+
g: pixelValue(radius * 100 + time + 200),
147+
b: pixelValue(radius * 100 + time + 400),
148+
});
149+
}
150+
}
151+
this.animationFrame = requestAnimationFrame(this.animateStep);
152+
};
153+
154+
updated() {
155+
if (this.animation && !this.animationFrame) {
156+
this.animationFrame = requestAnimationFrame(this.animateStep);
157+
} else if (!this.animation && this.animationFrame) {
158+
cancelAnimationFrame(this.animationFrame);
159+
this.animationFrame = null;
160+
}
161+
}
162+
163+
private renderPixels() {
164+
const result = [];
165+
const { cols, rows, colSpacing, rowSpacing } = this;
166+
const patWidth = pixelWidth + colSpacing;
167+
const patHeight = pixelHeight + rowSpacing;
168+
for (let row = 0; row < rows; row++) {
169+
for (let col = 0; col < cols; col++) {
170+
result.push(svg`
171+
<g transform="translate(${patWidth * col}, ${patHeight * row})" class="pixel">
172+
<ellipse cx="2.5" cy="2.3" rx="0.3" ry="0.3" fill="red" opacity="0" />
173+
<ellipse cx="3.5" cy="3.2" rx="0.3" ry="0.3" fill="green" opacity="0" />
174+
<ellipse cx="3.3" cy="1.45" rx="0.3" ry="0.3" fill="blue" opacity="0" />
175+
<ellipse cx="3" cy="2.5" rx="2.2" ry="2.2" opacity="0" />
176+
</g>`);
177+
}
178+
}
179+
180+
this.pixelElements = null;
181+
182+
return result;
183+
}
184+
185+
render() {
186+
const { cols, rows, rowSpacing, colSpacing, blurLight } = this;
187+
const patWidth = pixelWidth + colSpacing;
188+
const patHeight = pixelHeight + rowSpacing;
189+
const width = pixelWidth * cols + colSpacing * (cols - 1);
190+
const height = pixelHeight * rows + rowSpacing * (rows - 1);
191+
return html`
192+
<svg
193+
width="${width}mm"
194+
height="${height}mm"
195+
version="1.1"
196+
viewBox="0 0 ${width} ${height}"
197+
xmlns="http://www.w3.org/2000/svg"
198+
>
199+
<filter id="blurLight" x="-0.8" y="-0.8" height="2.8" width="2.8">
200+
<feGaussianBlur stdDeviation="0.3" />
201+
</filter>
202+
203+
<pattern id="pixel" width="${patWidth}" height="${patHeight}" patternUnits="userSpaceOnUse">
204+
<rect x=".33308" y="0" width="5" height="5" fill="#fff" />
205+
<rect x=".016709" y=".4279" width=".35114" height=".9" fill="#eaeaea" />
206+
<rect x="0" y="3.6518" width=".35114" height=".9" fill="#eaeaea" />
207+
<rect x="5.312" y="3.6351" width=".35114" height=".9" fill="#eaeaea" />
208+
<rect x="5.312" y=".3945" width=".35114" height=".9" fill="#eaeaea" />
209+
<circle cx="2.8331" cy="2.5" r="2.1" fill="#ddd" />
210+
<circle cx="2.8331" cy="2.5" r="1.7325" fill="#e6e6e6" />
211+
<g fill="#bfbfbf">
212+
<path
213+
d="m4.3488 3.3308s-0.0889-0.087-0.0889-0.1341c0-0.047-6e-3 -1.1533-6e-3 -1.1533s-0.0591-0.1772-0.2008-0.1772c-0.14174 0-0.81501 0.012-0.81501 0.012s-0.24805 0.024-0.23624 0.3071c0.0118 0.2835 0.032 2.0345 0.032 2.0345 0.54707-0.046 1.0487-0.3494 1.3146-0.8888z"
214+
/>
215+
<path
216+
d="m4.34 1.6405h-1.0805s-0.24325 0.019-0.26204-0.2423l6e-3 -0.6241c0.57782 0.075 1.0332 0.3696 1.3366 0.8706z"
217+
/>
218+
<path
219+
d="m2.7778 2.6103-0.17127 0.124-0.8091-0.012c-0.17122-0.019-0.17062-0.2078-0.17062-0.2078-1e-3 -0.3746 1e-3 -0.2831-9e-3 -0.8122l-0.31248-0.018s0.43453-0.9216 1.4786-0.9174c-1.1e-4 0.6144-4e-3 1.2289-6e-3 1.8434z"
220+
/>
221+
<path
222+
d="m2.7808 3.0828-0.0915-0.095h-0.96857l-0.0915 0.1447-3e-3 0.1127c0 0.065-0.12108 0.08-0.12108 0.08h-0.20909c0.55906 0.9376 1.4867 0.9155 1.4867 0.9155 1e-3 -0.3845-2e-3 -0.7692-2e-3 -1.1537z"
223+
/>
224+
</g>
225+
<path
226+
d="m4.053 1.8619c-0.14174 0-0.81494 0.013-0.81494 0.013s-0.24797 0.024-0.23616 0.3084c3e-3 0.077 5e-3 0.3235 9e-3 0.5514h1.247c-2e-3 -0.33-4e-3 -0.6942-4e-3 -0.6942s-0.0593-0.1781-0.20102-0.1781z"
227+
fill="#666"
228+
/>
229+
</pattern>
230+
<rect width="${width}" height="${height}" fill="url(#pixel)"></rect>
231+
<g style="${blurLight ? 'filter: url(#blurLight)' : ''}">
232+
${this.renderPixels()}
233+
</g>
234+
</svg>
235+
`;
236+
}
237+
}

0 commit comments

Comments
 (0)