aboutsummaryrefslogtreecommitdiff
path: root/src_js
diff options
context:
space:
mode:
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.ts36
-rw-r--r--src_js/dragger.js28
-rw-r--r--src_js/dragger.ts42
-rw-r--r--src_js/file.ts31
-rw-r--r--src_js/main.ts (renamed from src_js/main.js)10
-rw-r--r--src_js/notification.ts7
-rw-r--r--src_js/states.js33
-rw-r--r--src_js/vt.js515
-rw-r--r--src_js/vt/index.ts52
-rw-r--r--src_js/vt/input.ts86
-rw-r--r--src_js/vt/params.ts282
-rw-r--r--src_js/vt/result.ts84
-rw-r--r--src_js/vt/svg.ts138
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)
+ ])
+ ];
+ })
+ ];
+}