diff options
| author | s-ol <s-ol@users.noreply.github.com> | 2020-10-04 12:19:25 +0000 |
|---|---|---|
| committer | s-ol <s-ol@users.noreply.github.com> | 2020-10-04 12:19:25 +0000 |
| commit | 1ab9ed5b297048c5d3bc0278eddeb5a1d1c80367 (patch) | |
| tree | 929cd8dd9eaa99894e4f7da9b2ad0f6e5be1abd6 /app | |
| download | isomorphic-kb-explorer-1ab9ed5b297048c5d3bc0278eddeb5a1d1c80367.tar.gz isomorphic-kb-explorer-1ab9ed5b297048c5d3bc0278eddeb5a1d1c80367.zip | |
initial commit
Diffstat (limited to 'app')
| -rw-r--r-- | app/app.js | 155 | ||||
| -rw-r--r-- | app/config.js | 155 | ||||
| -rw-r--r-- | app/css.js | 13 | ||||
| -rw-r--r-- | app/index.js | 10 |
4 files changed, 333 insertions, 0 deletions
diff --git a/app/app.js b/app/app.js new file mode 100644 index 0000000..27c0e38 --- /dev/null +++ b/app/app.js @@ -0,0 +1,155 @@ +import React from 'react'; +import css from './css'; + +const statbyte = (stat, chan) => (stat << 4) | chan; +const noteon = (key, vel=127, chan=0) => [statbyte(0b1001, chan), key, vel]; +const noteoff = (key, vel=127, chan=0) => [statbyte(0b1000, chan), key, vel]; +const ccchange = (ctl, val, chan=0) => [statbyte(0b1011, chan), ctl, val]; + +const clamp = (val, min=0, max=1) => Math.max(min, Math.min(val, max)); +const ramp = i => new Array(i).fill(true).map((_, i) => i); +const mix = (i, a, b) => i * a + (1-i) * b; + +window.ccchange = ccchange; +window.noteon = noteon; + +const parse = message => ({ + cmd: message.data[0] >> 4, + chan: message.data[0] & 0xf, + note: message.data[1], + val: message.data[2] / 127, +}); + +const longSize = 3; +const rowStaggered = true; +const w = longSize * (rowStaggered ? Math.sqrt(3) : 2 ); +const h = longSize * (rowStaggered ? 2 : Math.sqrt(3)); +const sx = longSize * (rowStaggered ? Math.sqrt(3) : 1.5 ); +const sy = longSize * (rowStaggered ? 1.5 : Math.sqrt(3)); +const rem = (n) => `${n}rem`; + +css(` +body { + background: #212121; + margin: 2rem; + + font-family: sans-serif; +} + +.app { + position: relative; +} + +.hexagon { + position: absolute; + width: ${rem(w)}; + height: ${rem(h)}; +} + +.hexagon > .inner { + margin: auto; + width: ${rem(Math.min(w, h) * 0.8)}; + height: ${rem(Math.min(w, h) * 0.8)}; + padding: ${rem(longSize * 0.4)}; + border-radius: ${rem(longSize)}; + box-sizing: border-box; + font-size: ${rem(longSize * 0.5)}; + text-align: center; + background: #696969; + color: #eeeeee; + cursor: pointer; + + transition: background 300ms; +} +.hexagon > .inner:hover { + background: #848484; +} +.hexagon > .inner:active, +.hexagon.active > .inner { + background: #eeeeee; + color: #696969; +} +`); + +const Hexagon = ({ x, y, children, state }) => { + if (rowStaggered) { + if (y % 2 == 0) + x += 0.5; + } else { + if (x % 2 == 0) + y += 0.5; + } + + return ( + <div + className={'hexagon' + (state ? ' active' : '')} + style={{ + top: rem(y * sy), + left: rem(x * sx), + }} + > + <div + className="inner" + onClick={() => console.info(x,y)} + > + {children} + </div> + </div> + ); +}; + + +const range = i => new Array(i).fill(true); + +export default class App extends React.Component { + state = {}; + + componentDidUpdate(prevProps) { + if (prevProps.midiin) + prevProps.midiin.onmidimessage = null; + + if (this.props.midiin) + this.props.midiin.onmidimessage = this.onmessage; + } + + onmessage = (e) => { + const message = parse(e); + + switch (message.cmd) { + case 9: + // NOTE_ON + this.setState(old => ({ ...old, [message.note]: true })); + break; + + case 8: + // NOTE_OFF + this.setState(old => ({ ...old, [message.note]: false })); + break; + + case 11: + // CC + break; + } + } + + render() { + const xs = 10; + const ys = 4; + + return ( + <div className="app"> + {range(xs).map((_, x) => ( + range(ys).map((_, y) => { + let note = 36 + 2 * x + 6 * (ys - y); + if (y % 2 == 0) note += 1; + return ( + <Hexagon key={x + ',' + y} x={x} y={y} note={note} state={this.state[note]}> + {note} + </Hexagon> + ); + }) + ))} + </div> + ); + } +}; diff --git a/app/config.js b/app/config.js new file mode 100644 index 0000000..e5f2b0c --- /dev/null +++ b/app/config.js @@ -0,0 +1,155 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import css from './css'; + +const setState = (key, state) => { + try { + localStorage.setItem(key, JSON.stringify(state)); + return true; + } + catch (e) { + return false; + } +}; + +const loadState = (key, defaultState=null) => { + try { + const data = window.localStorage.getItem(key); + return Object.assign({}, defaultState, data && JSON.parse(data)); + } + catch (e) { + return defaultState; + } +}; + +css(` + .settings { + width: 500px; + margin: auto; + padding: 1em; + text-align: left; + } + + .setting { + margin: 0.4em 0; + } + + .setting > label { + display: inline-block; + width: 200px; + } + + .setting > select, .setting > input, .settings button { + width: 200px; + margin-left: 20px; + } + + .setting > select:disabled { + color: #aaa; + background: #444; + } + + .settings > button { + text-align: center; + margin-right: 20px; + } +`); + +const Dropdown = ({ name, list, set, disabled, value }) => ( + <div className="setting"> + <label>{name}</label> + <select value={value} onChange={e => set(e, e.target.value)} disabled={!list || disabled}> + <option>(none)</option> + {list && ([...list.values()]).map((port) => ( + <option value={port.id} key={port.id}>{port.name}</option> + ))} + </select> + </div> +); + +const Checkbox = ({ name, set, value }) => ( + <div className="setting"> + <label>{name}</label> + <input + type="checkbox" + onChange={e => set(e, !value)} + checked={value} + /> + </div> +); + +export default WrappedComponent => + class SettingsStore extends React.Component { + constructor(props) { + super(props); + + this.state = loadState('settings', { configured: false, midiSupport: false }); + this.configure = () => this.setState({ configured: false }); + + this.reload = () => { + navigator.requestMIDIAccess({ sysex: true }) + .then(access => { + this.inputs = access.inputs; + this.outputs = access.outputs; + this.setState({ + midiSupport: true, + }); + this.forceUpdate(); + }); + } + } + + componentWillUpdate(props, state) { + setState('settings', state); + } + + componentDidMount() { + if (navigator.requestMIDIAccess) + this.reload(); + } + + set(k, v) { + return (e, vv) => { + e.stopPropagation(); + + this.setState({ [k]: v === undefined + ? vv + : v }); + } + } + + render() { + const { configured, midiin, midiout } = this.state; + + if (configured) { + return ( + <WrappedComponent + {...this.props} + midiin={this.inputs && this.inputs.get(midiin)} + midiout={this.outputs && this.outputs.get(midiout)} + configure={this.configure} + /> + ); + } + + return ( + <div className="panel settings"> + <h3>MIDI I/O</h3> + <Dropdown + name="MIDI Keyboard Input" + list={this.inputs} + set={this.set('midiin')} + value={midiin} + /> + <Dropdown + name="MIDI Output" + list={this.outputs} + set={this.set('midiout')} + value={midiout} + /> + <button onClick={this.reload}>reload MIDI device list</button> + <button onClick={this.set('configured', true)}>start</button> + </div> + ); + }; + }; diff --git a/app/css.js b/app/css.js new file mode 100644 index 0000000..842214a --- /dev/null +++ b/app/css.js @@ -0,0 +1,13 @@ +export default (code) => { + const style = document.createElement('style'); + document.head.appendChild(style); + style.appendChild(document.createTextNode('')); + + let i = 0; + for (const v of code.split('}')) { + if (v.indexOf('{') < 0) continue; + style.sheet.insertRule(v + '}', i++); + } + + return style; +}; diff --git a/app/index.js b/app/index.js new file mode 100644 index 0000000..6e2a51e --- /dev/null +++ b/app/index.js @@ -0,0 +1,10 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import App from './app'; +import config from './config'; + +const $App = config(App); + +const node = document.createElement('div'); +ReactDOM.render(<$App />, node); +document.body.appendChild(node); |
