|
| 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