├── lib ├── Lab.ts ├── SxPropsTheme.ts ├── wlmap.ts ├── cct.ts ├── download_blob.ts ├── duv.ts ├── locale.ts ├── use-interval.tsx ├── Oklab.ts ├── wl2rgb.ts ├── lm3calc.ts ├── exposure.ts ├── ab.ts ├── ssi.ts ├── CMF.ts ├── vector.ts ├── planckian.ts ├── spd.ts ├── flicker.ts ├── fftshift.ts ├── ble │ ├── index.ts │ └── lm3.ts ├── CIEConv.ts ├── tint.ts ├── matrix.ts ├── spline.ts ├── global.ts ├── cri.ts └── spdIlluminants.ts ├── .npmrc ├── .gitignore ├── .eslintrc.json ├── public └── favicon.ico ├── .prettierrc.js ├── next-env.d.ts ├── components ├── Tint.tsx ├── MyHead.tsx ├── CCT.tsx ├── Title │ ├── PowerStatus.tsx │ ├── index.tsx │ └── Notifications.tsx ├── Duv.tsx ├── MeasControl.tsx ├── SetupDialog.tsx ├── WarningDialog.tsx ├── settings │ ├── Parameters.tsx │ ├── SettingsCard.tsx │ └── ble.tsx ├── BatteryLevel.tsx ├── Chart.tsx ├── Polar.tsx ├── CIE1931.tsx └── Memory.tsx ├── styles ├── globals.css └── Home.module.css ├── README.md ├── tsconfig.json ├── .editorconfig ├── pages ├── _app.js ├── wb.tsx ├── setup │ └── index.tsx ├── _document.js ├── index.tsx ├── color.tsx ├── ssi.tsx ├── exposure.tsx ├── spectrum.tsx ├── cri.tsx ├── flicker.tsx └── text.tsx └── package.json /lib/Lab.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | save-exact=true 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.next 2 | /node_modules 3 | *.swp 4 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- 1 | 2 | 📹 3 | 4 | -------------------------------------------------------------------------------- /lib/SxPropsTheme.ts: -------------------------------------------------------------------------------- 1 | import { SxProps } from '@mui/system'; 2 | import { Theme } from '@mui/material'; 3 | 4 | type SxPropsTheme = SxProps; 5 | export default SxPropsTheme; 6 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | arrowParens: 'always', 3 | bracketSpacing: true, 4 | printWidth: 120, 5 | semi: true, 6 | singleQuote: true, 7 | tabWidth: 4, 8 | trailingComma: 'es5', 9 | useTabs: true, 10 | }; 11 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/pages/api-reference/config/typescript for more information. 6 | -------------------------------------------------------------------------------- /components/Tint.tsx: -------------------------------------------------------------------------------- 1 | import TextField from '@mui/material/TextField'; 2 | 3 | export default function Tint({ value }: { value: number }) { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /lib/wlmap.ts: -------------------------------------------------------------------------------- 1 | export default function wlMap( 2 | fn: (wl: number, index: number) => U, 3 | increment: number = 5, 4 | min: number = 380, 5 | max: number = 780 6 | ) { 7 | return Array.from({ length: (max - min) / increment + 1 }, (_, index) => fn(min + index * increment, index)); 8 | } 9 | -------------------------------------------------------------------------------- /components/MyHead.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | 3 | export default function MyHead({ title }: { title?: string }) { 4 | return ( 5 | 6 | {`Open Light Master ${title ?? ''}`} 7 | 8 | 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /styles/globals.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | padding: 0; 4 | margin: 0; 5 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, 6 | Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; 7 | } 8 | 9 | a { 10 | color: inherit; 11 | text-decoration: none; 12 | } 13 | 14 | * { 15 | box-sizing: border-box; 16 | } 17 | -------------------------------------------------------------------------------- /lib/cct.ts: -------------------------------------------------------------------------------- 1 | export default function calcCCT(x: number, y: number) { 2 | // McCamy’s (CCT) formula 3 | // RFE Explore better formulas 4 | // - Hernández-Andrés et al. formula 5 | // - Accurate method for computing correlated color temperature, Changjun Li et al. 6 | const n = (x - 0.332) / (0.1858 - y); 7 | const CCT = 449 * n * n * n + 3525 * n * n + 6823.3 * n + 5520.33; 8 | 9 | return CCT; 10 | } 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Open Light Master 2 | ================= 3 | 4 | https://open-light-master.vercel.app/ 5 | 6 | An open source "web app" for the Opple Light Master 3 (III). 7 | 8 | Features 9 | -------- 10 | 11 | - Exposure meter 12 | - Lux, ft⋅cd 13 | - CCT, WB, Duv, Tint, 14 | - CRI (Ra, R1-R14), SSI 15 | - Spectrum (SPD) 16 | - Flicker analysis (FFT) 17 | - CSV Export, printable reports 18 | - Memory Save/Recall 19 | -------------------------------------------------------------------------------- /components/CCT.tsx: -------------------------------------------------------------------------------- 1 | import TextField from '@mui/material/TextField'; 2 | import InputAdornment from '@mui/material/InputAdornment'; 3 | 4 | export default function CCT({ value }: { value: number }) { 5 | return ( 6 | K, 13 | }} 14 | /> 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /components/Title/PowerStatus.tsx: -------------------------------------------------------------------------------- 1 | import { BatteryLevel } from 'components/BatteryLevel'; 2 | import { useGlobalState } from 'lib/global'; 3 | import { useEffect } from 'react'; 4 | 5 | export default function PowerStatus() { 6 | // @ts-ignore 7 | // The whole battery_status stuff is commented out for now 8 | const [batteryLevel] = useGlobalState('res_battery_level'); 9 | 10 | useEffect(() => {}, [batteryLevel]); 11 | 12 | return ; 13 | } 14 | -------------------------------------------------------------------------------- /lib/download_blob.ts: -------------------------------------------------------------------------------- 1 | export default function downloadBlob(blob: Blob, filename: string) { 2 | const url = URL.createObjectURL(blob); 3 | const a = document.createElement('a'); 4 | 5 | a.href = url; 6 | a.download = filename; 7 | 8 | const clickHandler = () => { 9 | setTimeout(() => { 10 | URL.revokeObjectURL(url); 11 | a.removeEventListener('click', clickHandler); 12 | }, 150); 13 | }; 14 | 15 | a.addEventListener('click', clickHandler, false); 16 | a.click(); 17 | 18 | return a; 19 | } 20 | -------------------------------------------------------------------------------- /lib/duv.ts: -------------------------------------------------------------------------------- 1 | const k = Object.freeze([-0.471106, 1.925865, -2.4243787, 1.5317403, -0.5179722, 0.0893944, -0.00616793]); 2 | 3 | // ANSI C78.377-2011 4 | export default function calcDuv(u: number, v: number) { 5 | const Lfp = Math.sqrt(Math.pow(u - 0.292, 2) + Math.pow(v - 0.24, 2)); 6 | const a = Math.acos((u - 0.292) / Lfp); 7 | const Lbb = 8 | k[6] * Math.pow(a, 6) + 9 | k[5] * Math.pow(a, 5) + 10 | k[4] * Math.pow(a, 4) + 11 | k[3] * Math.pow(a, 3) + 12 | k[2] * Math.pow(a, 2) + 13 | k[1] * a + 14 | k[0]; 15 | 16 | return Lfp - Lbb; 17 | } 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "baseUrl": ".", 5 | "lib": [ 6 | "dom", 7 | "dom.iterable", 8 | "esnext" 9 | ], 10 | "allowJs": true, 11 | "skipLibCheck": true, 12 | "strict": false, 13 | "forceConsistentCasingInFileNames": true, 14 | "noEmit": true, 15 | "esModuleInterop": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "jsx": "preserve", 21 | "incremental": true 22 | }, 23 | "include": [ 24 | "next-env.d.ts", 25 | "**/*.ts", 26 | "**/*.tsx" 27 | ], 28 | "exclude": [ 29 | "node_modules" 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /components/Duv.tsx: -------------------------------------------------------------------------------- 1 | import TextField from '@mui/material/TextField'; 2 | 3 | export default function Duv({ value }: { value: number }) { 4 | // "The concept of correlated color temperature should not be used if the 5 | // chromaticity of the test source differs more than Δuv = 5×10-2 from the 6 | // Planckian radiator." 7 | // Schanda, János (2007). "3: CIE Colorimetry". 8 | // Colorimetry: Understanding the CIE System. 9 | const duvErr = Math.abs(value) > 5e-2; 10 | 11 | return ( 12 |  } 20 | /> 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /components/MeasControl.tsx: -------------------------------------------------------------------------------- 1 | import Box from '@mui/material/Box'; 2 | import IconButton from '@mui/material/IconButton'; 3 | import PauseCircleIcon from '@mui/icons-material/PauseCircle'; 4 | import PlayCircleIcon from '@mui/icons-material/PlayCircle'; 5 | import { useGlobalState } from 'lib/global'; 6 | 7 | export default function MeasControl() { 8 | const [running, setRunning] = useGlobalState('running'); 9 | const [lm3] = useGlobalState('lm3'); 10 | const toggle = () => setRunning(!running); 11 | 12 | return ( 13 | 14 | 21 | {lm3 && running ? : } 22 | 23 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /lib/locale.ts: -------------------------------------------------------------------------------- 1 | export function getClientLang(): string { 2 | if (typeof Intl !== 'undefined') { 3 | try { 4 | return Intl.NumberFormat().resolvedOptions().locale; 5 | } catch (_err) { 6 | if (window.navigator.languages) { 7 | return window.navigator.languages[0]; 8 | } else { 9 | // @ts-ignore 10 | return window.navigator.userLanguage || window.navigator.language; 11 | } 12 | } 13 | } 14 | 15 | return 'en-US'; 16 | } 17 | 18 | export function getDayPeriod(date: Date): string { 19 | return new Intl.DateTimeFormat(getClientLang(), { dayPeriod: 'short' }).format(date); 20 | } 21 | 22 | export function getDateTime(date: Date): string { 23 | return new Intl.DateTimeFormat(getClientLang(), { 24 | day: '2-digit', 25 | month: '2-digit', 26 | year: '2-digit', 27 | hour: '2-digit', 28 | minute: '2-digit', 29 | }).format(date); 30 | } 31 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | indent_size = 4 6 | tab_width = 4 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [{*.json,*.json.example,*.gyp,*.yml,*.yaml}] 13 | indent_style = space 14 | indent_size = 2 15 | 16 | [{*.py,*.asm}] 17 | indent_style = space 18 | 19 | [*.py] 20 | indent_size = 4 21 | 22 | [*.asm] 23 | indent_size = 8 24 | 25 | [*.md] 26 | trim_trailing_whitespace = false 27 | 28 | # Ideal settings - some plugins might support these. 29 | [*.js] 30 | quote_type = single 31 | 32 | [{*.c,*.cc,*.h,*.hh,*.cpp,*.hpp,*.m,*.mm,*.mpp,*.js,*.java,*.go,*.rs,*.php,*.ng,*.jsx,*.ts,*.d,*.cs,*.swift}] 33 | curly_bracket_next_line = false 34 | spaces_around_operators = true 35 | spaces_around_brackets = outside 36 | # close enough to 1TB 37 | indent_brace_style = K&R 38 | -------------------------------------------------------------------------------- /lib/use-interval.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from 'react'; 2 | 3 | // https://overreacted.io/making-setinterval-declarative-with-react-hooks/ 4 | // useInterval(() => { 5 | // // Your custom logic here 6 | // setCount(count + 1); 7 | // }, 1000); 8 | 9 | function useInterval(callback: () => Promise, delay: number | null) { 10 | const savedCallback = useRef<() => Promise>(); 11 | 12 | // Remember the latest callback. 13 | useEffect(() => { 14 | savedCallback.current = callback; 15 | }, [callback]); 16 | 17 | // Set up the interval. 18 | useEffect(() => { 19 | function tick() { 20 | savedCallback.current().catch(console.error); 21 | } 22 | if (delay !== null) { 23 | tick(); 24 | let id = setInterval(tick, delay); 25 | return () => clearInterval(id); 26 | } 27 | }, [delay]); 28 | } 29 | 30 | export default useInterval; 31 | -------------------------------------------------------------------------------- /lib/Oklab.ts: -------------------------------------------------------------------------------- 1 | import { matrixMul } from './matrix'; 2 | 3 | const M1 = [ 4 | [0.8189330101, 0.3618667424, -0.1288597137], 5 | [0.0329845436, 0.9293118715, 0.0361456387], 6 | [0.0482003018, 0.2643662691, 0.633851707], 7 | ]; 8 | const M2 = [ 9 | [0.2104542553, 0.793617785, -0.0040720468], 10 | [1.9779984951, -2.428592205, 0.4505937099], 11 | [0.0259040371, 0.7827717662, -0.808675766], 12 | ]; 13 | 14 | export function XYZD65toOklab(X: number, Y: number, Z: number): [number, number, number] { 15 | const [[l], [m], [s]] = matrixMul(M1, [[X], [Y], [Z]]); 16 | const q = 1 / 3; 17 | const lms_prime = [[l ** q], [m ** q], [s ** q]]; 18 | const Lab = matrixMul(M2, lms_prime); 19 | 20 | return [Lab[0][0], Lab[1][0], Lab[2][0]] as const; 21 | } 22 | 23 | export function Oklab2Oklch(L: number, a: number, b: number) { 24 | const C = Math.sqrt(a ** 2 + b ** 2); 25 | const h = Math.atan2(b, a); 26 | 27 | return [L, C, h] as const; 28 | } 29 | 30 | export function Oklch2Oklab(L: number, C: number, h: number) { 31 | const a = C * Math.cos(h); 32 | const b = C * Math.sin(h); 33 | 34 | return [L, a, b] as const; 35 | } 36 | -------------------------------------------------------------------------------- /lib/wl2rgb.ts: -------------------------------------------------------------------------------- 1 | export default function wavelengthToColor(wl: number): [string, number, number, number, number] { 2 | let R: number; 3 | let G: number; 4 | let B: number; 5 | let alpha: number; 6 | 7 | if (wl >= 380 && wl < 440) { 8 | R = (-1 * (wl - 440)) / (440 - 380); 9 | G = 0; 10 | B = 1; 11 | } else if (wl >= 440 && wl < 490) { 12 | R = 0; 13 | G = (wl - 440) / (490 - 440); 14 | B = 1; 15 | } else if (wl >= 490 && wl < 510) { 16 | R = 0; 17 | G = 1; 18 | B = (-1 * (wl - 510)) / (510 - 490); 19 | } else if (wl >= 510 && wl < 580) { 20 | R = (wl - 510) / (580 - 510); 21 | G = 1; 22 | B = 0; 23 | } else if (wl >= 580 && wl < 645) { 24 | R = 1; 25 | G = (-1 * (wl - 645)) / (645 - 580); 26 | B = 0.0; 27 | } else if (wl >= 645 && wl <= 780) { 28 | R = 1; 29 | G = 0; 30 | B = 0; 31 | } else { 32 | R = 0; 33 | G = 0; 34 | B = 0; 35 | } 36 | 37 | if (wl > 780 || wl < 380) { 38 | alpha = 0; 39 | } else if (wl > 700) { 40 | alpha = (780 - wl) / (780 - 700); 41 | } else if (wl < 420) { 42 | alpha = (wl - 380) / (420 - 380); 43 | } else { 44 | alpha = 1; 45 | } 46 | 47 | return [`rgba(${R * 100}%,${G * 100}%,${B * 100}%,${alpha})`, R, G, B, alpha]; 48 | } 49 | -------------------------------------------------------------------------------- /lib/lm3calc.ts: -------------------------------------------------------------------------------- 1 | import { calcCRI } from './cri'; 2 | import { interpolateSPD } from './spd'; 3 | import { normalize2 } from './vector'; 4 | 5 | type MeasurementData = { 6 | V1: number; 7 | B1: number; 8 | G1: number; 9 | Y1: number; 10 | O1: number; 11 | R1: number; 12 | Lux: number; 13 | CCT: number; 14 | }; 15 | 16 | export default function lm3CalcCRI(meas: MeasurementData) { 17 | const spd = interpolateSPD([ 18 | { l: 450, v: meas.V1 }, 19 | { l: 500, v: meas.B1 }, 20 | { l: 550, v: meas.G1 }, 21 | { l: 570, v: meas.Y1 }, 22 | { l: 600, v: meas.O1 }, 23 | { l: 650, v: meas.R1 }, 24 | ]); 25 | 26 | return calcCRI( 27 | meas.CCT, 28 | Float64Array.from(spd, ({ v }) => v) 29 | ); 30 | } 31 | 32 | export function lm3NormSPD(meas: MeasurementData) { 33 | const norm = normalize2([meas.V1, meas.B1, meas.G1, meas.Y1, meas.O1, meas.R1]); 34 | return Object.freeze([ 35 | { 36 | l: 450, 37 | v: norm[0], 38 | }, 39 | { 40 | l: 500, 41 | v: norm[1], 42 | }, 43 | { 44 | l: 550, 45 | v: norm[2], 46 | }, 47 | { 48 | l: 570, 49 | v: norm[3], 50 | }, 51 | { 52 | l: 600, 53 | v: norm[4], 54 | }, 55 | { 56 | l: 650, 57 | v: norm[5], 58 | }, 59 | ]); 60 | } 61 | -------------------------------------------------------------------------------- /components/SetupDialog.tsx: -------------------------------------------------------------------------------- 1 | import { useState, Fragment, ReactNode } from 'react'; 2 | import Button from '@mui/material/Button'; 3 | import Dialog from '@mui/material/Dialog'; 4 | import DialogActions from '@mui/material/DialogActions'; 5 | import DialogContent from '@mui/material/DialogContent'; 6 | import DialogTitle from '@mui/material/DialogTitle'; 7 | 8 | export default function SetupDialog({ 9 | btnText, 10 | title, 11 | children, 12 | }: { 13 | btnText: ReactNode; 14 | title: string; 15 | children: ReactNode; 16 | }) { 17 | const [open, setOpen] = useState(false); 18 | 19 | const handleClickOpen = () => { 20 | setOpen(true); 21 | }; 22 | 23 | const handleClose = () => { 24 | setOpen(false); 25 | }; 26 | 27 | return ( 28 | 29 | 32 | 33 | {title} 34 | {children} 35 | 36 | 39 | 40 | 41 | 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /components/WarningDialog.tsx: -------------------------------------------------------------------------------- 1 | import Button from '@mui/material/Button'; 2 | import Dialog from '@mui/material/Dialog'; 3 | import DialogActions from '@mui/material/DialogActions'; 4 | import DialogContent from '@mui/material/DialogContent'; 5 | import DialogContentText from '@mui/material/DialogContentText'; 6 | import DialogTitle from '@mui/material/DialogTitle'; 7 | 8 | export default function WarningDialog({ 9 | title, 10 | show, 11 | handleCancel, 12 | handleContinue, 13 | children: message, 14 | }: { 15 | title: string; 16 | show: boolean; 17 | handleCancel: (e: any) => void; 18 | handleContinue: (e: any) => void; 19 | children?: any; 20 | }) { 21 | return ( 22 | 28 | {title} 29 | 30 | {message || ''} 31 | 32 | 33 | 36 | 39 | 40 | 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /pages/_app.js: -------------------------------------------------------------------------------- 1 | import { ThemeProvider, createTheme } from '@mui/material/styles'; 2 | import '../styles/globals.css' 3 | import CssBaseline from '@mui/material/CssBaseline'; 4 | import { red } from '@mui/material/colors'; 5 | import PropTypes from 'prop-types'; 6 | import createCache from '@emotion/cache'; 7 | import { CacheProvider } from '@emotion/react'; 8 | 9 | export const cache = createCache({ 10 | key: 'css', 11 | prepend: true, 12 | }); 13 | 14 | // Create a theme instance. 15 | const theme = createTheme({ 16 | palette: { 17 | background: { 18 | default: '#fafafa', 19 | }, 20 | primary: { 21 | main: '#1976D2', 22 | }, 23 | secondary: { 24 | main: red.A400, 25 | }, 26 | error: { 27 | main: red.A400, 28 | }, 29 | }, 30 | }); 31 | 32 | function App({ Component, pageProps }) { 33 | return ( 34 | 35 | 36 | {/* CssBaseline kickstart an elegant, consistent, and simple baseline to build upon. */} 37 | 38 | 39 | 40 | 41 | ); 42 | } 43 | 44 | export default App 45 | 46 | App.propTypes = { 47 | Component: PropTypes.elementType.isRequired, 48 | emotionCache: PropTypes.object, 49 | pageProps: PropTypes.object.isRequired, 50 | }; 51 | -------------------------------------------------------------------------------- /pages/wb.tsx: -------------------------------------------------------------------------------- 1 | import Box from '@mui/system/Box'; 2 | import Container from '@mui/material/Container'; 3 | import Paper from '@mui/material/Paper'; 4 | import CCT from 'components/CCT'; 5 | import CIE1931 from 'components/CIE1931'; 6 | import Duv from 'components/Duv'; 7 | import Memory from 'components/Memory'; 8 | import MyHead from 'components/MyHead'; 9 | import Tint from 'components/Tint'; 10 | import Title from 'components/Title'; 11 | import { useGlobalState, useMemoryRecall } from 'lib/global'; 12 | 13 | export default function Text() { 14 | const [meas] = useGlobalState('res_lm_measurement'); 15 | const secondary = useMemoryRecall().map(({ name, meas }) => ({ 16 | label: name, 17 | Ex: meas.Ex, 18 | Ey: meas.Ey, 19 | CCT: meas.CCT, 20 | Duv: meas.Duv, 21 | })); 22 | 23 | return ( 24 | 25 | 26 | 27 | OLM - WB 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /components/settings/Parameters.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import TextField from '@mui/material/TextField'; 3 | import DisplaySettingsIcon from '@mui/icons-material/DisplaySettings'; 4 | import { SettingsCard, iconStyle } from './SettingsCard'; 5 | import { useGlobalState } from 'lib/global'; 6 | 7 | export default function Parameters() { 8 | const [hz, setHz] = useGlobalState('hz'); 9 | const [avg, setAvg] = useGlobalState('avg'); 10 | const [hzError, setHzError] = useState(''); 11 | const [avgError, setAvgError] = useState(''); 12 | 13 | return ( 14 | } title="Parameters"> 15 | { 22 | const newHz = parseInt(e.target.value); 23 | if (Number.isNaN(newHz) || newHz <= 0 || newHz > 100) { 24 | setHzError('Incorrect entry.'); 25 | } else { 26 | if (hzError) setHzError(''); 27 | setHz(newHz); 28 | } 29 | }} 30 | /> 31 |
32 |
33 | { 40 | const newAvg = parseInt(e.target.value); 41 | if (Number.isNaN(newAvg) || newAvg < 0 || newAvg > 300) { 42 | setAvgError('Incorrect entry.'); 43 | } else { 44 | if (avgError) setAvgError(''); 45 | setAvg(newAvg); 46 | } 47 | }} 48 | /> 49 |
50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "open-light-master", 3 | "version": "1.0.0", 4 | "private": true, 5 | "engines": { 6 | "node": ">=20.0.0" 7 | }, 8 | "scripts": { 9 | "dev": "NEXT_TELEMETRY_DISABLED=${NEXT_TELEMETRY_DISABLED:-1} next dev --turbopack", 10 | "build": "NEXT_TELEMETRY_DISABLED=${NEXT_TELEMETRY_DISABLED:-1} next build", 11 | "clean": "rm -rf .next", 12 | "start": "NEXT_TELEMETRY_DISABLED=${NEXT_TELEMETRY_DISABLED:-1} next start", 13 | "prettier": "prettier --write './{pages,components,lib}/**/*.{ts,tsx}'", 14 | "lint": "NEXT_TELEMETRY_DISABLED=${NEXT_TELEMETRY_DISABLED:-1} next lint" 15 | }, 16 | "dependencies": { 17 | "@emotion/css": "11.13.5", 18 | "@emotion/react": "11.14.0", 19 | "@emotion/server": "11.11.0", 20 | "@emotion/styled": "11.14.0", 21 | "@mui/icons-material": "5.15.15", 22 | "@mui/material": "5.15.15", 23 | "@mui/x-data-grid": "7.1.1", 24 | "chartjs-plugin-annotation": "3.0.1", 25 | "chartjs-plugin-datalabels": "2.2.0", 26 | "next": "15.1.9", 27 | "prop-types": "15.8.1", 28 | "react": "18.3.1", 29 | "react-chartjs-2": "5.2.0", 30 | "react-dom": "18.3.1", 31 | "react-hooks-global-state": "2.1.0", 32 | "react-material-ui-carousel": "3.4.2", 33 | "webfft": "1.0.3" 34 | }, 35 | "devDependencies": { 36 | "@types/node": "20.14.8", 37 | "@types/react": "18.3.12", 38 | "@types/web-bluetooth": "0.0.21", 39 | "eslint": "9.21.0", 40 | "eslint-config-next": "15.2.1", 41 | "prettier": "3.5.3", 42 | "typescript": "5.8.2" 43 | }, 44 | "overrides": { 45 | "@types/react": "18.3.12" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /pages/setup/index.tsx: -------------------------------------------------------------------------------- 1 | import Box from '@mui/material/Box'; 2 | import Container from '@mui/material/Container'; 3 | import Grid from '@mui/material/Grid'; 4 | import MyHead from 'components/MyHead'; 5 | import Parameters from 'components/settings/Parameters'; 6 | import Title from 'components/Title'; 7 | import { MemorySettings } from 'components/Memory'; 8 | import Ble from 'components/settings/ble'; 9 | import { BLE_SERVICE_UUID as LM3_SERVICE_UUID, createLm3 } from 'lib/ble/lm3'; 10 | import { getGlobalState, useGlobalState } from 'lib/global'; 11 | import { Paired } from 'lib/ble'; 12 | 13 | function LM3() { 14 | const [, setDevice] = useGlobalState('lm3'); 15 | const connectCb = async (server: BluetoothRemoteGATTServer) => { 16 | const lm3 = await createLm3(server); 17 | await lm3.startNotifications(); 18 | await lm3.readCal(); 19 | lm3.startMeasuring((1 / getGlobalState('hz')) * 1000, getGlobalState('avg')); 20 | setDevice(lm3); 21 | }; 22 | const disconnectCb = (_btd: Paired) => { 23 | setDevice(null); 24 | }; 25 | 26 | return ( 27 | 34 | ); 35 | } 36 | 37 | export default function Setup() { 38 | return ( 39 | 40 | 41 | 42 | Setup 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /lib/exposure.ts: -------------------------------------------------------------------------------- 1 | const fStops = Object.freeze([ 2 | 0.5, 0.7, 0.8, 1.0, 1.2, 1.4, 1.7, 2.0, 2.4, 2.8, 3.3, 4.0, 4.8, 5.6, 6.7, 8.0, 9.5, 11, 13, 16, 19, 22, 27, 32, 38, 3 | 45, 54, 64, 76, 90, 107, 128, 180, 256, 4 | ]); 5 | 6 | const sspeeds = Object.freeze([ 7 | 1 / 8000, 8 | 1 / 4000, 9 | 1 / 2000, 10 | 1 / 1000, 11 | 1 / 800, 12 | 1 / 500, 13 | 1 / 400, 14 | 1 / 320, 15 | 1 / 250, 16 | 1 / 200, 17 | 1 / 125, 18 | 1 / 60, 19 | 1 / 30, 20 | 1 / 15, 21 | 1 / 8, 22 | 1 / 4, 23 | 1 / 2, 24 | 1, 25 | 5 / 6, 26 | 2 / 3, 27 | 2, 28 | 4, 29 | 8, 30 | 15, 31 | 25, 32 | 30, 33 | 60, 34 | 120, 35 | ]); 36 | 37 | function closest(needle: number, haystack: readonly number[]): number { 38 | return haystack.reduce((a, b) => { 39 | let aDiff = Math.abs(a - needle); 40 | let bDiff = Math.abs(b - needle); 41 | 42 | if (aDiff === bDiff) { 43 | return a > b ? a : b; 44 | } else { 45 | return bDiff < aDiff ? b : a; 46 | } 47 | }); 48 | } 49 | 50 | export function closestShutter(shutter: number): number { 51 | return closest(shutter, sspeeds); 52 | } 53 | 54 | export function closestAperture(fstop: number): number { 55 | return closest(fstop, fStops); 56 | } 57 | 58 | export function calcEV(lux: number, iso: number = 100, gain: number = 0): number { 59 | const C = 250; 60 | const EV100 = Math.log2((lux * 100) / C); 61 | 62 | if (iso === 100) { 63 | return EV100 + gain / 6; 64 | } 65 | 66 | return EV100 + Math.log2(iso / 100) + gain / 6; 67 | } 68 | 69 | export function calcShutter(ev: number, fstop: number): number { 70 | return (fstop * fstop) / 2 ** ev; 71 | } 72 | 73 | export function calcFstop(ev: number, shutter: number): number { 74 | return Math.sqrt(shutter * 2 ** ev); 75 | } 76 | -------------------------------------------------------------------------------- /components/BatteryLevel.tsx: -------------------------------------------------------------------------------- 1 | import BatteryUnknownIcon from '@mui/icons-material/BatteryUnknown'; 2 | import Battery0Icon from '@mui/icons-material/BatteryAlert'; 3 | import Battery20Icon from '@mui/icons-material/Battery20'; 4 | import Battery30Icon from '@mui/icons-material/Battery30'; 5 | import Battery50Icon from '@mui/icons-material/Battery50'; 6 | import Battery60Icon from '@mui/icons-material/Battery60'; 7 | import Battery80Icon from '@mui/icons-material/Battery80'; 8 | import Battery90Icon from '@mui/icons-material/Battery90'; 9 | import Battery100Icon from '@mui/icons-material/BatteryFull'; 10 | import ElectricalServicesIcon from '@mui/icons-material/ElectricalServices'; 11 | import SxPropsTheme from 'lib/SxPropsTheme'; 12 | 13 | const style: SxPropsTheme = { 14 | verticalAlign: 'center', 15 | fontSize: '25px !important', 16 | }; 17 | 18 | function BatteryIcon({ batteryLevel }: { batteryLevel: number }) { 19 | return batteryLevel < 0 ? ( 20 | 21 | ) : batteryLevel < 20 ? ( 22 | 23 | ) : batteryLevel < 30 ? ( 24 | 25 | ) : batteryLevel < 50 ? ( 26 | 27 | ) : batteryLevel < 60 ? ( 28 | 29 | ) : batteryLevel < 80 ? ( 30 | 31 | ) : batteryLevel < 90 ? ( 32 | 33 | ) : batteryLevel < 100 ? ( 34 | 35 | ) : ( 36 | 37 | ); 38 | } 39 | 40 | export function BatteryLevel({ batteryLevel }: { batteryLevel: number }) { 41 | return ; 42 | } 43 | 44 | export function PowerAdapter() { 45 | return ; 46 | } 47 | -------------------------------------------------------------------------------- /lib/ab.ts: -------------------------------------------------------------------------------- 1 | export function arrayBufferToString(buffer: ArrayBuffer, encoding: string): Promise { 2 | return new Promise((resolve) => { 3 | const blob = new Blob([buffer], { type: 'text/plain' }); 4 | const reader = new FileReader(); 5 | 6 | reader.onload = (e) => resolve(e.target.result as string); 7 | reader.readAsText(blob, encoding); 8 | }); 9 | } 10 | 11 | export function stringToArrayBuffer(str: string, encoding: string): Promise { 12 | return new Promise((resolve) => { 13 | const blob = new Blob([str], { type: `text/plain;charset=${encoding}` }); 14 | const reader = new FileReader(); 15 | 16 | reader.onload = (evt) => resolve(evt.target.result as ArrayBuffer); 17 | reader.readAsArrayBuffer(blob); 18 | }); 19 | } 20 | 21 | export function arrayBufferToBase64(buf: ArrayBuffer): string { 22 | if (typeof window === 'undefined') { 23 | return Buffer.from(buf).toString('base64'); 24 | } else { 25 | return window.btoa(String.fromCharCode.apply(null, new Uint8Array(buf))); 26 | } 27 | } 28 | 29 | export function base64ToString(str: string): string { 30 | if (typeof window === 'undefined') { 31 | return Buffer.from(str, 'base64').toString(); 32 | } else { 33 | return window.atob(str); 34 | } 35 | } 36 | 37 | export async function stringToBase64(str: string): Promise { 38 | if (typeof window === 'undefined') { 39 | return Buffer.from(str).toString('base64'); 40 | } else { 41 | const buf = await stringToArrayBuffer(str, 'utf-8'); 42 | 43 | return arrayBufferToBase64(buf); 44 | } 45 | } 46 | 47 | export async function digestSHA1(str: string): Promise { 48 | const data = await stringToArrayBuffer(str, 'utf-8'); 49 | const digest = await crypto.subtle.digest('SHA-1', data); 50 | 51 | return arrayBufferToBase64(digest); 52 | } 53 | -------------------------------------------------------------------------------- /lib/ssi.ts: -------------------------------------------------------------------------------- 1 | import { SPD, interpolateSPD } from './spd'; 2 | import { matrixMul } from './matrix'; 3 | import { normalize3, sub as vecSub, convolve } from './vector'; 4 | 5 | const trap30x301 = Object.preventExtensions( 6 | (() => { 7 | const trap = [0.5, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0.5]; 8 | const out = Array.from({ length: 30 }, () => Array.from({ length: 301 }, () => 0)); 9 | 10 | for (let i = 0; i < 30; i++) { 11 | for (let j = 0; j < trap.length; j++) { 12 | out[i][10 * i + j] = trap[j]; 13 | } 14 | } 15 | 16 | return out; 17 | })() 18 | ); 19 | 20 | const spectralWeight = Object.freeze([ 21 | 12 / 45, 22 | 22 / 45, 23 | 32 / 45, 24 | 40 / 45, 25 | 44 / 45, 26 | 1, 27 | 1, 28 | 1, 29 | 1, 30 | 1, 31 | 1, 32 | 1, 33 | 1, 34 | 1, 35 | 1, 36 | 1, 37 | 1, 38 | 1, 39 | 1, 40 | 1, 41 | 1, 42 | 1, 43 | 1, 44 | 1, 45 | 1, 46 | 1, 47 | 1, 48 | 1, 49 | 11 / 15, 50 | 3 / 15, 51 | ]); 52 | const convVec = Object.freeze([0.22, 0.56, 0.22]); 53 | 54 | export function ssi(ref: Readonly, test: Readonly) { 55 | const refVec = matrixMul( 56 | trap30x301, 57 | interpolateSPD(ref, 1, 380, 670).map(({ v }) => [v]) 58 | ).map(([v]) => v); // step 1 59 | const testVec = matrixMul( 60 | trap30x301, 61 | interpolateSPD(test, 1, 380, 670).map(({ v }) => [v]) 62 | ).map(([v]) => v); 63 | 64 | const refNorm = normalize3(refVec); // step 2 65 | const testNorm = normalize3(testVec); 66 | 67 | const diffNorm = vecSub(testNorm, refNorm); // step 3 68 | const diffRela = diffNorm.map((x, i) => x / (refNorm[i] + 1 / refNorm.length)); // step 4 69 | const weightedDiffRela = diffRela.map((x, i) => x * spectralWeight[i]); // step 5 70 | const sWeightedDiffRela = convolve([0, ...weightedDiffRela, 0], convVec); // step 6 71 | const metric = sWeightedDiffRela.reduce((sum, cur) => sum + cur ** 2); // step 7 72 | const SSI = Math.round(100 - 32 * Math.sqrt(metric)); // step 8 73 | 74 | return SSI; 75 | } 76 | -------------------------------------------------------------------------------- /lib/CMF.ts: -------------------------------------------------------------------------------- 1 | // 2deg CIE 1931 Color Matching Functions 2 | // x(lambda), y(lambda), z(lambda) 3 | // 380 - 780 nm 4 | // step: 5 nm 5 | export const CIE1931_2DEG_CMF = Float64Array.from([ 6 | 0.0014, 0.0, 0.0065, 0.0022, 0.0001, 0.0105, 0.0042, 0.0001, 0.0201, 0.0076, 0.0002, 0.0362, 0.0143, 0.0004, 0.0679, 7 | 0.0232, 0.0006, 0.1102, 0.0435, 0.0012, 0.2074, 0.0776, 0.0022, 0.3713, 0.1344, 0.004, 0.6456, 0.2148, 0.0073, 8 | 1.0391, 0.2839, 0.0116, 1.3856, 0.3285, 0.0168, 1.623, 0.3483, 0.023, 1.7471, 0.3481, 0.0298, 1.7825, 0.3362, 0.038, 9 | 1.7721, 0.3187, 0.048, 1.7441, 0.2908, 0.06, 1.6692, 0.2511, 0.0739, 1.5281, 0.1954, 0.091, 1.2876, 0.1421, 0.1126, 10 | 1.0419, 0.0956, 0.139, 0.813, 0.058, 0.1693, 0.6162, 0.032, 0.208, 0.4652, 0.0147, 0.2586, 0.3533, 0.0049, 0.323, 11 | 0.272, 0.0024, 0.4073, 0.2123, 0.0093, 0.503, 0.1582, 0.0291, 0.6082, 0.1117, 0.0633, 0.71, 0.0782, 0.1096, 0.7932, 12 | 0.0573, 0.1655, 0.862, 0.0422, 0.2257, 0.9149, 0.0298, 0.2904, 0.954, 0.0203, 0.3597, 0.9803, 0.0134, 0.4333, 0.995, 13 | 0.0087, 0.5121, 1.0, 0.0057, 0.5945, 0.995, 0.0039, 0.6784, 0.9786, 0.0027, 0.7621, 0.952, 0.0021, 0.8425, 0.9154, 14 | 0.0018, 0.9163, 0.87, 0.0017, 0.9786, 0.8163, 0.0014, 1.0263, 0.757, 0.0011, 1.0567, 0.6949, 0.001, 1.0622, 0.631, 15 | 0.0008, 1.0456, 0.5668, 0.0006, 1.0026, 0.503, 0.0003, 0.9384, 0.4412, 0.0002, 0.8544, 0.381, 0.0002, 0.7514, 0.321, 16 | 0.0, 0.6424, 0.265, 0.0, 0.5419, 0.217, 0.0, 0.4479, 0.175, 0.0, 0.3608, 0.1382, 0.0, 0.2835, 0.107, 0.0, 0.2187, 17 | 0.0816, 0.0, 0.1649, 0.061, 0.0, 0.1212, 0.0446, 0.0, 0.0874, 0.032, 0.0, 0.0636, 0.0232, 0.0, 0.0468, 0.017, 0.0, 18 | 0.0329, 0.0119, 0.0, 0.0227, 0.0082, 0.0, 0.0158, 0.0057, 0.0, 0.0114, 0.0041, 0.0, 0.0081, 0.0029, 0.0, 0.0058, 19 | 0.0021, 0.0, 0.0041, 0.0015, 0.0, 0.0029, 0.001, 0.0, 0.002, 0.0007, 0.0, 0.0014, 0.0005, 0.0, 0.001, 0.0003, 0.0, 20 | 0.0007, 0.00025, 0.0, 0.0005, 0.0002, 0.0, 0.0003, 0.0001, 0.0, 0.0003, 0.0001, 0.0, 0.0002, 0.0001, 0.0, 0.0002, 21 | 0.0001, 0.0, 0.0001, 0.0001, 0.0, 0.0001, 0.0, 0.0, 0.0, 0.0, 0.0, 22 | ]); 23 | -------------------------------------------------------------------------------- /pages/_document.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Document, { Html, Head, Main, NextScript } from 'next/document'; 3 | import createEmotionServer from '@emotion/server/create-instance'; 4 | import { cache } from './_app.js'; 5 | 6 | const { extractCritical } = createEmotionServer(cache); 7 | 8 | export default class MyDocument extends Document { 9 | render() { 10 | return ( 11 | 12 | 13 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | ); 24 | } 25 | } 26 | 27 | // `getInitialProps` belongs to `_document` (instead of `_app`), 28 | // it's compatible with static-site generation (SSG). 29 | MyDocument.getInitialProps = async (ctx) => { 30 | // Resolution order 31 | // 32 | // On the server: 33 | // 1. app.getInitialProps 34 | // 2. page.getInitialProps 35 | // 3. document.getInitialProps 36 | // 4. app.render 37 | // 5. page.render 38 | // 6. document.render 39 | // 40 | // On the server with error: 41 | // 1. document.getInitialProps 42 | // 2. app.render 43 | // 3. page.render 44 | // 4. document.render 45 | // 46 | // On the client 47 | // 1. app.getInitialProps 48 | // 2. page.getInitialProps 49 | // 3. app.render 50 | // 4. page.render 51 | 52 | // Render app and page and get the context of the page with collected side effects. 53 | const initialProps = await Document.getInitialProps(ctx); 54 | const styles = extractCritical(initialProps.html); 55 | 56 | return { 57 | ...initialProps, 58 | // Styles fragment is rendered after the app and page rendering finish. 59 | styles: [ 60 | ...React.Children.toArray(initialProps.styles), 61 |