diff options
| author | bozo.kopic <bozo@kopic.xyz> | 2022-11-13 03:39:02 +0100 |
|---|---|---|
| committer | bozo.kopic <bozo@kopic.xyz> | 2022-11-13 03:39:02 +0100 |
| commit | b30a00a9713fd52865129132317beb6fa875017c (patch) | |
| tree | c367859b2cfaf820c88eb4822e566d06144ecd4b /src_js | |
| parent | 26b2dee4ef3f0a00bd6fb2989ada48ad9b054972 (diff) | |
type script
Diffstat (limited to 'src_js')
| -rw-r--r-- | src_js/common.ts (renamed from src_js/common.js) | 237 | ||||
| -rw-r--r-- | src_js/csv.ts | 36 | ||||
| -rw-r--r-- | src_js/dragger.js | 28 | ||||
| -rw-r--r-- | src_js/dragger.ts | 42 | ||||
| -rw-r--r-- | src_js/file.ts | 31 | ||||
| -rw-r--r-- | src_js/main.ts (renamed from src_js/main.js) | 10 | ||||
| -rw-r--r-- | src_js/notification.ts | 7 | ||||
| -rw-r--r-- | src_js/states.js | 33 | ||||
| -rw-r--r-- | src_js/vt.js | 515 | ||||
| -rw-r--r-- | src_js/vt/index.ts | 52 | ||||
| -rw-r--r-- | src_js/vt/input.ts | 86 | ||||
| -rw-r--r-- | src_js/vt/params.ts | 282 | ||||
| -rw-r--r-- | src_js/vt/result.ts | 84 | ||||
| -rw-r--r-- | src_js/vt/svg.ts | 138 |
14 files changed, 902 insertions, 679 deletions
diff --git a/src_js/common.js b/src_js/common.ts index 1d718b8..793ce7a 100644 --- a/src_js/common.js +++ b/src_js/common.ts @@ -1,21 +1,109 @@ -import * as URI from 'uri-js'; -import FileSaver from 'file-saver'; -import iziToast from 'izitoast'; -import Papa from 'papaparse'; - import r from '@hat-open/renderer'; import * as u from '@hat-open/util'; -import * as states from './states'; - - -const calculateUrl = URI.resolve(window.location.href, './calculate'); -const generateUrl = URI.resolve(window.location.href, './generate'); +import * as csv from './csv'; +import * as file from './file'; +import * as notification from './notification'; + + +export type FormPanel = { + name: string, + quantity: number, + width: number, + height: number +}; + +export type FormItem = { + name: string, + quantity: number, + width: number, + height: number + can_rotate: boolean +}; + +export type Params = { + cut_width: number, + min_initial_usage: boolean, + panels: Record<string, { + width: number, + height: number + }>, + items: Record<string, { + width: number, + height: number + can_rotate: boolean + }> +}; + +export type Used = { + panel: string, + item: string, + x: number, + y: number, + rotate: boolean +}; + +export type Unused = { + panel: string, + width: number, + height: number, + x: number, + y: number +}; + +export type Result = { + params: Params, + used: Used[], + unused: Unused[] +}; + + +const calculateUrl = String(new URL('./calculate', window.location.href)); +const generateUrl = String(new URL('./generate', window.location.href)); let panelCounter = 0; let itemCounter = 0; +export const defaultState = { + form: { + method: 'forward_greedy_native', + cut_width: 0.3, + min_initial_usage: true, + panels: [], + items: [] + }, + result: null, + selected: { + panel: null, + item: null + }, + svg: { + font_size: '1', + show_names: true, + show_dimensions: false + }, + calculating: false +}; + + +const defaultFormPanel: FormPanel = { + name: 'Panel', + quantity: 1, + width: 100, + height: 100 +}; + + +const defaultFormItem: FormItem = { + name: 'Item', + quantity: 1, + width: 10, + height: 10, + can_rotate: true +}; + + export async function calculate() { r.set('calculating', true); try { @@ -39,9 +127,9 @@ export async function calculate() { )); if (!result) throw 'Could not resolve calculation'; - showNotification('success', 'New calculation available'); + notification.show('success', 'New calculation available'); } catch (e) { - showNotification('error', e); + notification.show('error', String(e)); } finally { r.set('calculating', false); } @@ -59,75 +147,85 @@ export async function generate() { if (!res.ok) throw await res.text(); const blob = await res.blob(); - FileSaver.saveAs(blob, 'output.pdf'); + const f = new File([blob], 'output.pdf'); + file.save(f); } catch (e) { - showNotification('error', e); + notification.show('error', String(e)); } } export async function csvImportPanels() { - const panels = await csvImport({ + const f = await file.load('.csv'); + if (f == null) + return; + const panels = await csv.decode(f, { name: String, quantity: u.strictParseInt, width: u.strictParseFloat, height: u.strictParseFloat - }); - r.change(['form', 'panels'], x => x.concat(panels)); + }) as FormPanel[]; + r.change(['form', 'panels'], x => (x as FormPanel[]).concat(panels)); } export function csvExportPanels() { - const panels = r.get('form', 'panels'); - const csvData = Papa.unparse(panels); - const blob = new Blob([csvData], {type: 'text/csv'}); - FileSaver.saveAs(blob, 'panels.csv'); + const panels = r.get('form', 'panels') as FormPanel[]; + const blob = csv.encode(panels); + const f = new File([blob], 'panels.csv'); + file.save(f); } export async function csvImportItems() { - const items = await csvImport({ + const f = await file.load('.csv'); + if (f == null) + return; + const items = await csv.decode(f, { name: String, quantity: u.strictParseInt, width: u.strictParseFloat, height: u.strictParseFloat, can_rotate: u.equals('true') - }); - r.change(['form', 'items'], x => x.concat(items)); + }) as FormItem[]; + r.change(['form', 'items'], x => (x as FormItem[]).concat(items)); } export function csvExportItems() { - const items = r.get('form', 'items'); - const csvData = Papa.unparse(items); - const blob = new Blob([csvData], {type: 'text/csv'}); - FileSaver.saveAs(blob, 'items.csv'); + const items = r.get('form', 'items') as FormItem[]; + const blob = csv.encode(items); + const f = new File([blob], 'items.csv'); + file.save(f); } export function addPanel() { panelCounter += 1; - const panel = u.set('name', `Panel${panelCounter}`, states.panel); - r.change(['form', 'panels'], u.append(panel)); + const panel = u.set('name', `Panel${panelCounter}`, defaultFormPanel); + r.change(['form', 'panels'], u.append(panel) as any); } export function addItem() { itemCounter += 1; - const item = u.set('name', `Item${itemCounter}`, states.item); - r.change(['form', 'items'], u.append(item)); + const item = u.set('name', `Item${itemCounter}`, defaultFormItem); + r.change(['form', 'items'], u.append(item) as any); } function createCalculateParams() { - const cutWidth = u.strictParseFloat(r.get('form', 'cut_width')); + const cutWidth = r.get('form', 'cut_width') as number; if (cutWidth < 0) throw 'Invalid cut width'; const minInitialUsage = r.get('form', 'min_initial_usage'); - const panels = {}; - for (const panel of r.get('form', 'panels')) { + const panels: Record<string, { + width: number, + height: number + }> = {}; + for (const panel of (r.get('form', 'panels') as FormPanel[])) { if (!panel.name) throw 'Invalid panel name'; if (panel.quantity < 1) @@ -146,8 +244,12 @@ function createCalculateParams() { if (u.equals(panels, {})) throw 'No panels defined'; - const items = {}; - for (const item of r.get('form', 'items')) { + const items: Record<string, { + width: number, + height: number, + can_rotate: boolean + }> = {}; + for (const item of (r.get('form', 'items') as FormItem[])) { if (!item.name) throw 'Invalid item name'; if (item.quantity < 1) @@ -178,64 +280,3 @@ function createCalculateParams() { items: items }; } - - -function showNotification(type, message) { - iziToast[type]({message: message}); -} - - -async function csvImport(header) { - const file = await loadFile('.csv'); - if (!file) - return []; - - const data = await new Promise(resolve => { - Papa.parse(file, { - header: true, - complete: result => resolve(result.data) - }); - }); - - const result = []; - for (const i of data) { - let element = {}; - for (const [k, v] of Object.entries(header)) { - if (!(k in i)) { - element = null; - break; - } - element[k] = v(i[k]); - } - if (element) - result.push(element); - } - return result; -} - - -async function loadFile(ext) { - const el = document.createElement('input'); - el.style = 'display: none'; - el.type = 'file'; - el.accept = ext; - document.body.appendChild(el); - - const promise = new Promise(resolve => { - const listener = evt => { - resolve(u.get(['files', 0], evt.target)); - }; - el.addEventListener('change', listener); - - // TODO blur not fired on close??? - el.addEventListener('blur', listener); - - el.click(); - }); - - try { - return await promise; - } finally { - document.body.removeChild(el); - } -} diff --git a/src_js/csv.ts b/src_js/csv.ts new file mode 100644 index 0000000..8ab4f51 --- /dev/null +++ b/src_js/csv.ts @@ -0,0 +1,36 @@ +// @ts-ignore +import Papa from 'papaparse'; + + +export async function decode<TKey extends string>( + blob: Blob, + header: Record<TKey, (val: string) => any> +): Promise<Record<TKey, any>[]> { + const data = await new Promise(resolve => { + Papa.parse(blob, { + header: true, + complete: (result: any) => resolve(result.data) + }); + }) as Record<string, string>[]; + + const result: Record<TKey, any>[] = []; + for (const i of data) { + let element = {} as Record<TKey, any> | null; + for (const [k, v] of Object.entries(header)) { + if (!(k in i)) { + element = null; + break; + } + (element as any)[k] = (v as any)(i[k]); + } + if (element) + result.push(element); + } + return result; +} + + +export function encode(data: Record<string, any>[]): Blob { + const csvData = Papa.unparse(data); + return new Blob([csvData], {type: 'text/csv'}); +} diff --git a/src_js/dragger.js b/src_js/dragger.js deleted file mode 100644 index 78a59d5..0000000 --- a/src_js/dragger.js +++ /dev/null @@ -1,28 +0,0 @@ - - -const draggers = []; - - -export function mouseDownHandler(createMoveHandlerCb) { - return evt => { - draggers.push({ - initX: evt.screenX, - initY: evt.screenY, - moveHandler: createMoveHandlerCb(evt) - }); - }; -} - - -document.addEventListener('mousemove', evt => { - for (let dragger of draggers) { - const dx = evt.screenX - dragger.initX; - const dy = evt.screenY - dragger.initY; - dragger.moveHandler(evt, dx, dy); - } -}); - - -document.addEventListener('mouseup', _ => { - draggers.splice(0); -}); diff --git a/src_js/dragger.ts b/src_js/dragger.ts new file mode 100644 index 0000000..0aeda91 --- /dev/null +++ b/src_js/dragger.ts @@ -0,0 +1,42 @@ + +type MouseHandler = (evt: MouseEvent) => void; + +type MoveHandler = (evt: MouseEvent, dx: number, dy: number) => void; + +type CreateMoveHandler = (evt: MouseEvent) => MoveHandler; + +type Dragger = { + initX: number, + initY: number, + moveHandler: MoveHandler; +} + + +const draggers: Dragger[] = []; + + +export function mouseDownHandler( + createMoveHandlerCb: CreateMoveHandler +): MouseHandler { + return evt => { + draggers.push({ + initX: evt.screenX, + initY: evt.screenY, + moveHandler: createMoveHandlerCb(evt) + }); + }; +} + + +document.addEventListener('mousemove', evt => { + for (const dragger of draggers) { + const dx = evt.screenX - dragger.initX; + const dy = evt.screenY - dragger.initY; + dragger.moveHandler(evt, dx, dy); + } +}); + + +document.addEventListener('mouseup', () => { + draggers.splice(0); +}); diff --git a/src_js/file.ts b/src_js/file.ts new file mode 100644 index 0000000..0466339 --- /dev/null +++ b/src_js/file.ts @@ -0,0 +1,31 @@ + + +export function load(ext: string): Promise<File | null> { + const el = document.createElement('input'); + (el as any).style = 'display: none'; + el.type = 'file'; + el.accept = ext; + document.body.appendChild(el); + + return new Promise<File | null>(resolve => { + const listener = (evt: Event) => { + const f = (evt.target as HTMLInputElement).files?.[0] ?? null; + document.body.removeChild(el); + resolve(f); + }; + el.addEventListener('change', listener); + // TODO blur not fired on close??? + el.addEventListener('blur', listener); + el.click(); + }); +} + + +export function save(f: File) { + const a = document.createElement('a'); + a.download = f.name; + a.rel = 'noopener'; + a.href = URL.createObjectURL(f); + setTimeout(() => { URL.revokeObjectURL(a.href); }, 20000); + setTimeout(() => { a.click(); }, 0); +} diff --git a/src_js/main.js b/src_js/main.ts index 140064d..6a9ca82 100644 --- a/src_js/main.js +++ b/src_js/main.ts @@ -1,18 +1,18 @@ import * as u from '@hat-open/util'; import r from '@hat-open/renderer'; -import * as states from './states'; -import * as vt from './vt'; +import * as common from './common'; +import * as vt from './vt/index'; import '../src_scss/main.scss'; function main() { const root = document.body.appendChild(document.createElement('div')); - r.init(root, states.main, vt.main); + r.init(root, common.defaultState, vt.main); } window.addEventListener('load', main); -window.r = r; -window.u = u; +(window as any).r = r; +(window as any).u = u; diff --git a/src_js/notification.ts b/src_js/notification.ts new file mode 100644 index 0000000..29a2d63 --- /dev/null +++ b/src_js/notification.ts @@ -0,0 +1,7 @@ +import iziToast from 'izitoast'; + + +export function show(type: string, message: string) { + const fn = (type == 'success' ? iziToast.success : iziToast.error); + fn.apply(iziToast, [{message: message}]); +} diff --git a/src_js/states.js b/src_js/states.js deleted file mode 100644 index 5f1599d..0000000 --- a/src_js/states.js +++ /dev/null @@ -1,33 +0,0 @@ - -export const main = { - form: { - method: 'forward_greedy_native', - cut_width: 0.3, - min_initial_usage: true, - panels: [], - items: [] - }, - result: null, - selected: { - panel: null, - item: null - }, - calculating: false -}; - - -export const panel = { - name: 'Panel', - quantity: 1, - width: 100, - height: 100 -}; - - -export const item = { - name: 'Item', - quantity: 1, - width: 10, - height: 10, - can_rotate: true -}; diff --git a/src_js/vt.js b/src_js/vt.js deleted file mode 100644 index 84d80a0..0000000 --- a/src_js/vt.js +++ /dev/null @@ -1,515 +0,0 @@ -import r from '@hat-open/renderer'; -import * as u from '@hat-open/util'; - -import * as common from './common'; -import * as dragger from './dragger'; - - -export function main() { - return ['div.main', - leftPanel(), - leftPanelResizer(), - centerPanel(), - rightPanelResizer(), - rightPanel() - ]; -} - - -function leftPanelResizer() { - return ['div.panel-resizer', { - on: { - mousedown: dragger.mouseDownHandler(evt => { - const panel = evt.target.parentNode.querySelector('.left-panel'); - const width = panel.clientWidth; - return (_, dx) => { - panel.style.width = `${width + dx}px`; - }; - }) - } - }]; -} - - -function rightPanelResizer() { - return ['div.panel-resizer', { - on: { - mousedown: dragger.mouseDownHandler(evt => { - const panel = evt.target.parentNode.querySelector('.right-panel'); - const width = panel.clientWidth; - return (_, dx) => { - panel.style.width = `${width - dx}px`; - }; - }) - } - }]; -} - - -function leftPanel() { - return ['div.left-panel', - ['div.header', - ['span.title', 'OPCUT'], - ['a.github', { - props: { - title: 'GitHub', - href: 'https://github.com/bozokopic/opcut' - }}, - ['span.fa.fa-github'] - ] - ], - ['div.form', - ['label', 'Method'], - selectInput(r.get('form', 'method'), - [['forward_greedy', 'Forward greedy'], - ['greedy', 'Greedy'], - ['forward_greedy_native', 'Forward greedy (native)'], - ['greedy_native', 'Greedy (native)'],], - val => r.set(['form', 'method'], val)), - ['label', 'Cut width'], - numberInput(r.get('form', 'cut_width'), - u.isNumber, - val => r.set(['form', 'cut_width'], val)), - ['label'], - checkboxInput('Minimize initial panel usage', - r.get('form', 'min_initial_usage'), - val => r.set(['form', 'min_initial_usage'], val)) - ], - ['div.content', - leftPanelPanels(), - leftPanelItems() - ], - ['button.calculate', { - props: { - disabled: r.get('calculating') - }, - on: { - click: common.calculate - }}, - 'Calculate' - ] - ]; -} - - -function leftPanelPanels() { - const panelsPath = ['form', 'panels']; - - const panelNames = new Set(); - const nameValidator = name => { - const valid = !panelNames.has(name); - panelNames.add(name); - return valid; - }; - - return ['div', - ['table', - ['thead', - ['tr', - ['th.col-name', 'Panel name'], - ['th.col-quantity', 'Quantity'], - ['th.col-height', 'Height'], - ['th.col-width', 'Width'], - ['th.col-delete'] - ] - ], - ['tbody', - r.get(panelsPath).map((panel, index) => ['tr', - ['td.col-name', - ['div', - textInput(panel.name, - nameValidator, - val => r.set([panelsPath, index, 'name'], val)) - ] - ], - ['td.col-quantity', - ['div', - numberInput(panel.quantity, - u.isInteger, - val => r.set([panelsPath, index, 'quantity'], val)) - ] - ], - ['td.col-height', - ['div', - numberInput(panel.height, - u.isNumber, - val => r.set([panelsPath, index, 'height'], val)) - ] - ], - ['td.col-width', - ['div', - numberInput(panel.width, - u.isNumber, - val => r.set([panelsPath, index, 'width'], val)) - ] - ], - ['td.col-delete', - ['button', { - on: { - click: _ => r.change(panelsPath, u.omit(index)) - }}, - ['span.fa.fa-minus'] - ] - ] - ]) - ], - ['tfoot', - ['tr', - ['td', { - props: { - colSpan: 5 - }}, - ['div', - ['button', { - on: { - click: common.addPanel - }}, - ['span.fa.fa-plus'], - ' Add' - ], - ['span.spacer'], - ['button', { - on: { - click: common.csvImportPanels - }}, - ['span.fa.fa-download'], - ' CSV Import' - ], - ['button', { - on: { - click: common.csvExportPanels - }}, - ['span.fa.fa-upload'], - ' CSV Export' - ] - ] - ] - ] - ] - ] - ]; -} - - -function leftPanelItems() { - const itemsPath = ['form', 'items']; - - const itemNames = new Set(); - const nameValidator = name => { - const valid = !itemNames.has(name); - itemNames.add(name); - return valid; - }; - - return ['div', - ['table', - ['thead', - ['tr', - ['th.col-name', 'Item name'], - ['th.col-quantity', 'Quantity'], - ['th.col-height', 'Height'], - ['th.col-width', 'Width'], - ['th.col-rotate', 'Rotate'], - ['th.col-delete'] - ] - ], - ['tbody', - r.get(itemsPath).map((item, index) => ['tr', - ['td.col-name', - ['div', - textInput(item.name, - nameValidator, - val => r.set([itemsPath, index, 'name'], val)) - ] - ], - ['td.col-quantity', - ['div', - numberInput(item.quantity, - u.isInteger, - val => r.set([itemsPath, index, 'quantity'], val)) - ] - ], - ['td.col-height', - ['div', - numberInput(item.height, - u.isNumber, - val => r.set([itemsPath, index, 'height'], val)) - ] - ], - ['td.col-width', - ['div', - numberInput(item.width, - u.isNumber, - val => r.set([itemsPath, index, 'width'], val)) - ] - ], - ['td.col-rotate', - ['div', - checkboxInput(null, - item.can_rotate, - val => r.set([itemsPath, index, 'can_rotate'], val)) - ] - ], - ['td.col-delete', - ['button', { - on: { - click: _ => r.change(itemsPath, u.omit(index)) - }}, - ['span.fa.fa-minus'] - ] - ] - ]) - ], - ['tfoot', - ['tr', - ['td', { - props: { - colSpan: 6 - }}, - ['div', - ['button', { - on: { - click: common.addItem - }}, - ['span.fa.fa-plus'], - ' Add' - ], - ['span.spacer'], - ['button', { - on: { - click: common.csvImportItems - }}, - ['span.fa.fa-download'], - ' CSV Import' - ], - ['button', { - on: { - click: common.csvExportItems - }}, - ['span.fa.fa-upload'], - ' CSV Export' - ] - ] - ] - ] - ] - ] - ]; -} - - -function rightPanel() { - const result = r.get('result'); - return ['div.right-panel', (!result ? - [] : - [ - ['div.toolbar', - ['button', { - on: { - click: common.generate - }}, - ['span.fa.fa-file-pdf-o'], - ' PDF' - ] - ], - Object.keys(result.params.panels).map(rightPanelPanel) - ]) - ]; -} - - -function rightPanelPanel(panel) { - const isSelected = item => u.equals(r.get('selected'), {panel: panel, item: item}); - - return ['div.panel', - ['div.panel-name', { - class: { - selected: isSelected(null) - }, - on: { - click: () => r.set('selected', {panel: panel, item: null}) - }}, - panel - ], - u.filter(used => used.panel == panel)(r.get('result', 'used')).map(used => - ['div.item', { - class: { - selected: isSelected(used.item) - }, - on: { - click: () => r.set('selected', {panel: panel, item: used.item}) - }}, - ['div.item-name', used.item], - (used.rotate ? ['span.item-rotate.fa.fa-refresh'] : []), - ['div.item-x', - 'X:', - String(Math.round(used.x * 100) / 100) - ], - ['div.item-y', - 'Y:', - String(Math.round(used.y * 100) / 100) - ] - ]) - ]; -} - - -function centerPanel() { - const result = r.get('result'); - const selected = r.get('selected'); - if (!result || !selected.panel) - return ['div.center-panel']; - - const panel = result.params.panels[selected.panel]; - const used = u.filter(i => i.panel == selected.panel, result.used); - const unused = u.filter(i => i.panel == selected.panel, result.unused); - const panelColor = 'rgb(100,100,100)'; - const itemColor = 'rgb(250,250,250)'; - const selectedItemColor = 'rgb(200,140,140)'; - const unusedColor = 'rgb(238,238,238)'; - const fontSize = String(Math.max(panel.height, panel.width) * 0.02); - - return ['div.center-panel', - ['svg', { - attrs: { - width: '100%', - height: '100%', - viewBox: [0, 0, panel.width, panel.height].join(' '), - preserveAspectRatio: 'xMidYMid' - }}, - ['rect', { - attrs: { - x: '0', - y: '0', - width: String(panel.width), - height: String(panel.height), - 'stroke-width': '0', - fill: panelColor - }} - ], - used.map(used => { - const item = result.params.items[used.item]; - const width = (used.rotate ? item.height : item.width); - const height = (used.rotate ? item.width : item.height); - return ['rect', { - props: { - style: 'cursor: pointer' - }, - attrs: { - x: String(used.x), - y: String(used.y), - width: String(width), - height: String(height), - 'stroke-width': '0', - fill: (used.item == selected.item ? selectedItemColor : itemColor) - }, - on: { - click: () => r.set(['selected', 'item'], used.item) - }} - ]; - }), - unused.map(unused => { - return ['rect', { - attrs: { - x: String(unused.x), - y: String(unused.y), - width: String(unused.width), - height: String(unused.height), - 'stroke-width': '0', - fill: unusedColor - }} - ]; - }), - used.map(used => { - const item = result.params.items[used.item]; - const width = (used.rotate ? item.height : item.width); - const height = (used.rotate ? item.width : item.height); - return ['text', { - props: { - style: 'cursor: pointer' - }, - attrs: { - x: String(used.x + width / 2), - y: String(used.y + height / 2), - 'alignment-baseline': 'middle', - 'text-anchor': 'middle', - 'font-size': fontSize - }, - on: { - click: () => r.set(['selected', 'item'], used.item) - }}, - used.item + (used.rotate ? ' \u293E' : '') - ]; - }) - ] - ]; -} - - -function textInput(value, validator, onChange) { - return ['input', { - props: { - type: 'text', - value: value - }, - class: { - invalid: validator && !validator(value) - }, - on: { - change: evt => onChange(evt.target.value) - } - }]; -} - - -function numberInput(value, validator, onChange) { - return ['input', { - props: { - type: 'number', - value: value - }, - class: { - invalid: validator && !validator(value) - }, - on: { - change: evt => onChange(evt.target.valueAsNumber) - } - }]; -} - - -function checkboxInput(label, value, onChange) { - const input = ['input', { - props: { - type: 'checkbox', - checked: value - }, - on: { - change: evt => onChange(evt.target.checked) - } - }]; - - if (!label) - return input; - - return ['label', - input, - ` ${label}` - ]; -} - - -function selectInput(selected, values, onChange) { - return ['select', { - on: { - change: evt => onChange(evt.target.value) - }}, - values.map(([value, label]) => ['option', { - props: { - selected: value == selected, - value: value - }}, - label - ]) - ]; -} diff --git a/src_js/vt/index.ts b/src_js/vt/index.ts new file mode 100644 index 0000000..74f571b --- /dev/null +++ b/src_js/vt/index.ts @@ -0,0 +1,52 @@ +import * as u from '@hat-open/util'; + +import * as dragger from '../dragger'; + +import * as params from './params'; +import * as result from './result'; +import * as svg from './svg'; + + +export function main(): u.VNode { + return ['div.main', + ['div.left-panel', params.main()], + leftPanelResizer(), + ['div.center-panel', svg.main() ?? []], + rightPanelResizer(), + ['div.right-panel', result.main()] + ]; +} + + +function leftPanelResizer(): u.VNode { + return ['div.panel-resizer', { + on: { + mousedown: dragger.mouseDownHandler(evt => { + const panel = (evt.target as HTMLElement).parentNode?.querySelector('.left-panel') as HTMLElement | null; + if (panel == null) + return () => {}; // eslint-disable-line + const width = panel.clientWidth; + return (_, dx) => { + panel.style.width = `${width + dx}px`; + }; + }) + } + }]; +} + + +function rightPanelResizer(): u.VNode { + return ['div.panel-resizer', { + on: { + mousedown: dragger.mouseDownHandler(evt => { + const panel = (evt.target as HTMLElement).parentNode?.querySelector('.right-panel') as HTMLElement | null; + if (panel == null) + return () => {}; // eslint-disable-line + const width = panel.clientWidth; + return (_, dx) => { + panel.style.width = `${width - dx}px`; + }; + }) + } + }]; +} diff --git a/src_js/vt/input.ts b/src_js/vt/input.ts new file mode 100644 index 0000000..749edbb --- /dev/null +++ b/src_js/vt/input.ts @@ -0,0 +1,86 @@ +import * as u from '@hat-open/util'; + + +export function text( + value: string, + validator: ((val: string) => boolean) | null, + onChange: (val: string) => void +): u.VNode { + return ['input', { + props: { + type: 'text', + value: value + }, + class: { + invalid: validator && !validator(value) + }, + on: { + change: (evt: Event) => onChange((evt.target as HTMLInputElement).value) + } + }]; +} + + +export function number( + value: number, + validator: ((val: number) => boolean) | null, + onChange: (val: number) => void +): u.VNode { + return ['input', { + props: { + type: 'number', + value: value + }, + class: { + invalid: validator && !validator(value) + }, + on: { + change: (evt: Event) => onChange((evt.target as HTMLInputElement).valueAsNumber) + } + }]; +} + + +export function checkbox( + label: string | null, + value: boolean, + onChange: (val: boolean) => void +): u.VNode { + const input: u.VNode = ['input', { + props: { + type: 'checkbox', + checked: value + }, + on: { + change: (evt: Event) => onChange((evt.target as HTMLInputElement).checked) + } + }]; + + if (!label) + return input; + + return ['label', + input, + ` ${label}` + ]; +} + + +export function select( + selected: string, + values: [string, string][], + onChange: (val: string) => void +): u.VNode { + return ['select', { + on: { + change: (evt: Event) => onChange((evt.target as HTMLSelectElement).value) + }}, + values.map(([value, label]) => ['option', { + props: { + selected: value == selected, + value: value + }}, + label + ]) + ]; +} diff --git a/src_js/vt/params.ts b/src_js/vt/params.ts new file mode 100644 index 0000000..3ac8848 --- /dev/null +++ b/src_js/vt/params.ts @@ -0,0 +1,282 @@ +import r from '@hat-open/renderer'; +import * as u from '@hat-open/util'; + +import * as common from '../common'; + +import * as input from './input'; + + +export function main(): u.VNodeChild[] { + return [ + ['div.header', + ['span.title', 'OPCUT'], + ['a.github', { + props: { + title: 'GitHub', + href: 'https://github.com/bozokopic/opcut' + }}, + ['span.fa.fa-github'] + ] + ], + ['div.form', + ['label', 'Method'], + input.select( + r.get('form', 'method') as string, + [['forward_greedy', 'Forward greedy'], + ['greedy', 'Greedy'], + ['forward_greedy_native', 'Forward greedy (native)'], + ['greedy_native', 'Greedy (native)']], + val => r.set(['form', 'method'], val) + ), + ['label', 'Cut width'], + input.number( + r.get('form', 'cut_width') as number, + u.isNumber, + val => r.set(['form', 'cut_width'], val) + ), + ['label'], + input.checkbox( + 'Minimize initial panel usage', + r.get('form', 'min_initial_usage') as boolean, + val => r.set(['form', 'min_initial_usage'], val) + ) + ], + ['div.content', + panels(), + items() + ], + ['button.calculate', { + props: { + disabled: r.get('calculating') + }, + on: { + click: common.calculate + }}, + 'Calculate' + ] + ]; +} + + +function panels(): u.VNode { + const panelsPath = ['form', 'panels']; + + const panelNames = new Set<string>(); + const nameValidator = (name: string) => { + const valid = !panelNames.has(name); + panelNames.add(name); + return valid; + }; + + return ['div', + ['table', + ['thead', + ['tr', + ['th.col-name', 'Panel name'], + ['th.col-quantity', 'Quantity'], + ['th.col-height', 'Height'], + ['th.col-width', 'Width'], + ['th.col-delete'] + ] + ], + ['tbody', + (r.get(panelsPath) as common.FormPanel[]).map((panel, index) => ['tr', + ['td.col-name', + ['div', + input.text( + panel.name, + nameValidator, + val => r.set([panelsPath, index, 'name'], val) + ) + ] + ], + ['td.col-quantity', + ['div', + input.number( + panel.quantity, + u.isInteger, + val => r.set([panelsPath, index, 'quantity'], val) + ) + ] + ], + ['td.col-height', + ['div', + input.number( + panel.height, + u.isNumber, + val => r.set([panelsPath, index, 'height'], val) + ) + ] + ], + ['td.col-width', + ['div', + input.number( + panel.width, + u.isNumber, + val => r.set([panelsPath, index, 'width'], val) + ) + ] + ], + ['td.col-delete', + ['button', { + on: { + click: () => r.change(panelsPath, u.omit(index)) + }}, + ['span.fa.fa-minus'] + ] + ] + ]) + ], + ['tfoot', + ['tr', + ['td', { + props: { + colSpan: 5 + }}, + ['div', + ['button', { + on: { + click: common.addPanel + }}, + ['span.fa.fa-plus'], + ' Add' + ], + ['span.spacer'], + ['button', { + on: { + click: common.csvImportPanels + }}, + ['span.fa.fa-download'], + ' CSV Import' + ], + ['button', { + on: { + click: common.csvExportPanels + }}, + ['span.fa.fa-upload'], + ' CSV Export' + ] + ] + ] + ] + ] + ] + ]; +} + + +function items(): u.VNode { + const itemsPath = ['form', 'items']; + + const itemNames = new Set<string>(); + const nameValidator = (name: string) => { + const valid = !itemNames.has(name); + itemNames.add(name); + return valid; + }; + + return ['div', + ['table', + ['thead', + ['tr', + ['th.col-name', 'Item name'], + ['th.col-quantity', 'Quantity'], + ['th.col-height', 'Height'], + ['th.col-width', 'Width'], + ['th.col-rotate', 'Rotate'], + ['th.col-delete'] + ] + ], + ['tbody', + (r.get(itemsPath) as common.FormItem[]).map((item, index) => ['tr', + ['td.col-name', + ['div', + input.text( + item.name, + nameValidator, + val => r.set([itemsPath, index, 'name'], val) + ) + ] + ], + ['td.col-quantity', + ['div', + input.number( + item.quantity, + u.isInteger, + val => r.set([itemsPath, index, 'quantity'], val) + ) + ] + ], + ['td.col-height', + ['div', + input.number( + item.height, + u.isNumber, + val => r.set([itemsPath, index, 'height'], val) + ) + ] + ], + ['td.col-width', + ['div', + input.number( + item.width, + u.isNumber, + val => r.set([itemsPath, index, 'width'], val) + ) + ] + ], + ['td.col-rotate', + ['div', + input.checkbox( + null, + item.can_rotate, + val => r.set([itemsPath, index, 'can_rotate'], val) + ) + ] + ], + ['td.col-delete', + ['button', { + on: { + click: () => r.change(itemsPath, u.omit(index)) + }}, + ['span.fa.fa-minus'] + ] + ] + ]) + ], + ['tfoot', + ['tr', + ['td', { + props: { + colSpan: 6 + }}, + ['div', + ['button', { + on: { + click: common.addItem + }}, + ['span.fa.fa-plus'], + ' Add' + ], + ['span.spacer'], + ['button', { + on: { + click: common.csvImportItems + }}, + ['span.fa.fa-download'], + ' CSV Import' + ], + ['button', { + on: { + click: common.csvExportItems + }}, + ['span.fa.fa-upload'], + ' CSV Export' + ] + ] + ] + ] + ] + ] + ]; +} diff --git a/src_js/vt/result.ts b/src_js/vt/result.ts new file mode 100644 index 0000000..fc23089 --- /dev/null +++ b/src_js/vt/result.ts @@ -0,0 +1,84 @@ +import r from '@hat-open/renderer'; +import * as u from '@hat-open/util'; + +import * as common from '../common'; + +import * as input from './input'; + + +export function main(): u.VNodeChild[] { + const result = r.get('result') as common.Result | null; + if (result == null) + return []; + return [ + ['div.form', + ['label', 'Export'], + ['div', + ['button', { + on: { + click: common.generate + }}, + ['span.fa.fa-file-pdf-o'], + ' PDF' + ] + ], + ['label', 'Font size'], + input.select( + r.get('svg', 'font_size') as string, + [['0.5', 'Small'], + ['1', 'Medium'], + ['1.5', 'Large']], + val => r.set(['svg', 'font_size'], val) + ), + ['label'], + input.checkbox( + 'Show names', + r.get('svg', 'show_names') as boolean, + val => r.set(['svg', 'show_names'], val) + ), + ['label'], + input.checkbox( + 'Show dimensions', + r.get('svg', 'show_dimensions') as boolean, + val => r.set(['svg', 'show_dimensions'], val) + ) + ], + Object.keys(result.params.panels).map(panelResult) + ]; +} + + +function panelResult(panel: string): u.VNode { + const isSelected = (item: string | null) => u.equals(r.get('selected'), {panel: panel, item: item}); + + return ['div.panel', + ['div.panel-name', { + class: { + selected: isSelected(null) + }, + on: { + click: () => r.set('selected', {panel: panel, item: null}) + }}, + panel + ], + u.filter(used => used.panel == panel, r.get('result', 'used') as common.Used[]).map(used => + ['div.item', { + class: { + selected: isSelected(used.item) + }, + on: { + click: () => r.set('selected', {panel: panel, item: used.item}) + }}, + ['div.item-name', used.item], + (used.rotate ? ['span.item-rotate.fa.fa-refresh'] : []), + ['div.item-x', + 'X:', + String(Math.round(used.x * 100) / 100) + ], + ['div.item-y', + 'Y:', + String(Math.round(used.y * 100) / 100) + ] + ]) + ]; +} diff --git a/src_js/vt/svg.ts b/src_js/vt/svg.ts new file mode 100644 index 0000000..1108e87 --- /dev/null +++ b/src_js/vt/svg.ts @@ -0,0 +1,138 @@ +import r from '@hat-open/renderer'; +import * as u from '@hat-open/util'; + +import * as common from '../common'; + + +export function main(): u.VNode | null { + const result = r.get('result') as common.Result | null; + const selected = r.get('selected') as { + panel: string | null, + item: string | null + }; + if (!result || !selected.panel) + return null; + + const panel = result.params.panels[selected.panel]; + const used = u.filter(i => i.panel == selected.panel, result.used); + const unused = u.filter(i => i.panel == selected.panel, result.unused); + const panelColor = 'rgb(100,100,100)'; + const itemColor = 'rgb(250,250,250)'; + const selectedItemColor = 'rgb(200,140,140)'; + const unusedColor = 'rgb(238,238,238)'; + const fontSize = String( + Math.max(panel.height, panel.width) * 0.02 * + u.strictParseFloat(r.get('svg', 'font_size') as string) + ); + const showNames = r.get('svg', 'show_names'); + const showDimensions = r.get('svg', 'show_dimensions'); + + return ['svg', { + attrs: { + width: '100%', + height: '100%', + viewBox: [0, 0, panel.width, panel.height].join(' '), + preserveAspectRatio: 'xMidYMid' + }}, + ['rect', { + attrs: { + x: '0', + y: '0', + width: String(panel.width), + height: String(panel.height), + 'stroke-width': '0', + fill: panelColor + }} + ], + used.map(used => { + const item = result.params.items[used.item]; + const width = (used.rotate ? item.height : item.width); + const height = (used.rotate ? item.width : item.height); + return ['rect', { + props: { + style: 'cursor: pointer' + }, + attrs: { + x: String(used.x), + y: String(used.y), + width: String(width), + height: String(height), + 'stroke-width': '0', + fill: (used.item == selected.item ? selectedItemColor : itemColor) + }, + on: { + click: () => r.set(['selected', 'item'], used.item) + }} + ]; + }), + unused.map(unused => { + return ['rect', { + attrs: { + x: String(unused.x), + y: String(unused.y), + width: String(unused.width), + height: String(unused.height), + 'stroke-width': '0', + fill: unusedColor + }} + ]; + }), + used.map(used => { + const item = result.params.items[used.item]; + const width = (used.rotate ? item.height : item.width); + const height = (used.rotate ? item.width : item.height); + const click = () => r.set(['selected', 'item'], used.item); + return [ + (!showNames ? [] : ['text', { + props: { + style: 'cursor: pointer' + }, + attrs: { + x: String(used.x + width / 2), + y: String(used.y + height / 2), + 'alignment-baseline': 'middle', + 'text-anchor': 'middle', + 'font-size': fontSize + }, + on: { + click: click + }}, + used.item + (used.rotate ? ' \u293E' : '') + ]), + (!showDimensions ? [] : ['text', { + props: { + style: 'cursor: pointer' + }, + attrs: { + x: String(used.x + width / 2), + y: String(used.y + height), + 'alignment-baseline': 'baseline', + 'text-anchor': 'middle', + 'font-size': fontSize + }, + on: { + click: click + }}, + String(width) + ]), + (!showDimensions ? [] : ['text', { + props: { + style: 'cursor: pointer' + }, + attrs: { + x: String(used.x + width), + y: String(used.y + height / 2), + 'transform': `rotate(-90, ${used.x + width}, ${used.y + height / 2})`, + 'alignment-baseline': 'baseline', + 'text-anchor': 'middle', + 'font-size': fontSize + }, + on: { + click: click + }}, + String(height) + ]) + ]; + }) + ]; +} |
