ListProperties can now emit update events.

This commit is contained in:
Daan Vanden Bosch 2019-09-03 16:44:13 +02:00
parent 1039049fda
commit 62db02e278
14 changed files with 280 additions and 75 deletions

View File

@ -22,7 +22,7 @@
box-sizing: border-box;
background-color: var(--control-bg-color);
height: 24px;
padding: 3px 8px;
padding: 3px 5px;
border: var(--control-inner-border);
overflow: hidden;
}
@ -45,6 +45,11 @@
color: hsl(0, 0%, 55%);
}
.core_Button_inner > * {
display: inline-block;
margin: 0 3px;
}
.core_Button_center {
flex: 1;
text-align: left;
@ -59,11 +64,3 @@
align-content: center;
font-size: 11px;
}
.core_Button_left {
padding: 0 6px 0 0;
}
.core_Button_right {
padding: 0 0 0 6px;
}

View File

@ -31,11 +31,12 @@ export class Button extends Control<HTMLButtonElement> {
super(el.button({ class: "core_Button" }, inner_element), options);
this.center_element = el.span({ class: "core_Button_center" });
if (options && options.icon_left != undefined) {
inner_element.append(el.span({ class: "core_Button_left" }, icon(options.icon_left)));
}
this.center_element = el.span({ class: "core_Button_center" });
inner_element.append(this.center_element);
if (options && options.icon_right != undefined) {
@ -71,5 +72,6 @@ export class Button extends Control<HTMLButtonElement> {
protected set_text(text: string): void {
this.center_element.textContent = text;
this.center_element.hidden = text === "";
}
}

View File

@ -54,7 +54,7 @@ export class FileButton extends Control<HTMLElement> {
);
}
inner_element.append(el.span({ text }));
inner_element.append(el.span({ class: "core_Button_center", text }));
this.element.append(inner_element, this.input);

View File

@ -6,6 +6,7 @@ import { Property } from "./property/Property";
import { DependentProperty } from "./property/DependentProperty";
import { WritableListProperty } from "./property/list/WritableListProperty";
import { SimpleWritableListProperty } from "./property/list/SimpleWritableListProperty";
import { Observable } from "./Observable";
export function emitter<E>(): Emitter<E> {
return new SimpleEmitter();
@ -15,8 +16,11 @@ export function property<T>(value: T): WritableProperty<T> {
return new SimpleProperty(value);
}
export function list_property<T>(...values: T[]): WritableListProperty<T> {
return new SimpleWritableListProperty(...values);
export function list_property<T>(
extract_observables?: (element: T) => Observable<any>[],
...elements: T[]
): WritableListProperty<T> {
return new SimpleWritableListProperty(extract_observables, ...elements);
}
export function add(left: Property<number>, right: number): Property<number> {

View File

@ -17,13 +17,13 @@ export abstract class AbstractMinimalProperty<T> implements Property<T> {
observe(
observer: (change: PropertyChangeEvent<T>) => void,
options: { call_now?: boolean } = {},
options?: { call_now?: boolean },
): Disposable {
if (!this.observers.includes(observer)) {
this.observers.push(observer);
}
if (options.call_now) {
if (options && options.call_now) {
this.call_observer(observer, this.val);
}

View File

@ -39,7 +39,7 @@ export type ListReplacement<T> = {
export type ListUpdate<T> = {
readonly type: ListChangeType.Update;
readonly update: T[];
readonly updated: T[];
readonly index: number;
};
@ -48,5 +48,8 @@ export interface ListProperty<T> extends Property<T[]> {
get(index: number): T;
observe_list(observer: (change: ListPropertyChangeEvent<T>) => void): Disposable;
observe_list(
observer: (change: ListPropertyChangeEvent<T>) => void,
options?: { call_now?: boolean },
): Disposable;
}

View File

@ -12,49 +12,80 @@ const logger = Logger.get("core/observable/property/list/SimpleWritableListPrope
export class SimpleWritableListProperty<T> extends AbstractProperty<T[]>
implements WritableListProperty<T> {
readonly length: Property<number>;
readonly length: Property<number>; // TODO: update length
get val(): T[] {
return this.get_val();
}
set val(values: T[]) {
this.set_val(values);
set val(elements: T[]) {
this.set_val(elements);
}
get_val(): T[] {
return this.values;
return this.elements;
}
set_val(values: T[]): T[] {
const removed = this.values.splice(0, this.values.length, ...values);
set_val(elements: T[]): T[] {
const removed = this.elements.splice(0, this.elements.length, ...elements);
this.emit_list({
type: ListChangeType.Replacement,
removed,
inserted: values,
inserted: elements,
from: 0,
removed_to: removed.length,
inserted_to: values.length,
inserted_to: elements.length,
});
return removed;
}
private readonly _length = property(0);
private readonly values: T[];
private readonly elements: T[];
private readonly extract_observables?: (element: T) => Observable<any>[];
/**
* Internal observers which observe observables related to this list's elements so that their
* changes can be propagated via update events.
*/
private readonly element_observers: { index: number; disposables: Disposable[] }[] = [];
/**
* External observers which are observing this list.
*/
private readonly list_observers: ((change: ListPropertyChangeEvent<T>) => void)[] = [];
constructor(...values: T[]) {
/**
* @param extract_observables - Extractor function called on each element in this list. Changes
* to the returned observables will be propagated via update events.
* @param elements - Initial elements of this list.
*/
constructor(extract_observables?: (element: T) => Observable<any>[], ...elements: T[]) {
super();
this.length = this._length;
this.values = values;
this.elements = elements;
this.extract_observables = extract_observables;
}
observe_list(observer: (change: ListPropertyChangeEvent<T>) => void): Disposable {
observe_list(
observer: (change: ListPropertyChangeEvent<T>) => void,
options?: { call_now?: true },
): Disposable {
if (this.element_observers.length === 0 && this.extract_observables) {
this.replace_element_observers(this.elements, 0, Infinity);
}
if (!this.list_observers.includes(observer)) {
this.list_observers.push(observer);
}
if (options && options.call_now) {
this.call_list_observer(observer, {
type: ListChangeType.Insertion,
inserted: this.elements,
from: 0,
to: this.elements.length,
});
}
return {
dispose: () => {
const index = this.list_observers.indexOf(observer);
@ -62,6 +93,16 @@ export class SimpleWritableListProperty<T> extends AbstractProperty<T[]>
if (index !== -1) {
this.list_observers.splice(index, 1);
}
if (this.list_observers.length === 0) {
for (const { disposables } of this.element_observers) {
for (const disposable of disposables) {
disposable.dispose();
}
}
this.element_observers.splice(0, Infinity);
}
},
};
}
@ -74,21 +115,21 @@ export class SimpleWritableListProperty<T> extends AbstractProperty<T[]>
/* TODO */ throw new Error("not implemented");
}
update(f: (value: T[]) => T[]): void {
this.splice(0, this.values.length, ...f(this.values));
update(f: (element: T[]) => T[]): void {
this.splice(0, this.elements.length, ...f(this.elements));
}
get(index: number): T {
return this.values[index];
return this.elements[index];
}
set(index: number, value: T): void {
const removed = [this.values[index]];
this.values[index] = value;
set(index: number, element: T): void {
const removed = [this.elements[index]];
this.elements[index] = element;
this.emit_list({
type: ListChangeType.Replacement,
removed,
inserted: [value],
inserted: [element],
from: index,
removed_to: index + 1,
inserted_to: index + 1,
@ -96,7 +137,7 @@ export class SimpleWritableListProperty<T> extends AbstractProperty<T[]>
}
clear(): void {
const removed = this.values.splice(0, this.values.length);
const removed = this.elements.splice(0, this.elements.length);
this.emit_list({
type: ListChangeType.Replacement,
removed,
@ -111,9 +152,9 @@ export class SimpleWritableListProperty<T> extends AbstractProperty<T[]>
let removed: T[];
if (delete_count == undefined) {
removed = this.values.splice(index);
removed = this.elements.splice(index);
} else {
removed = this.values.splice(index, delete_count, ...items);
removed = this.elements.splice(index, delete_count, ...items);
}
this.emit_list({
@ -129,14 +170,76 @@ export class SimpleWritableListProperty<T> extends AbstractProperty<T[]>
}
protected emit_list(change: ListPropertyChangeEvent<T>): void {
for (const observer of this.list_observers) {
try {
observer(change);
} catch (e) {
logger.error("Observer threw error.", e);
if (this.list_observers.length && this.extract_observables) {
switch (change.type) {
case ListChangeType.Insertion:
this.replace_element_observers(change.inserted, change.from, 0);
break;
case ListChangeType.Removal:
this.replace_element_observers([], change.from, change.removed.length);
break;
case ListChangeType.Replacement:
this.replace_element_observers(
change.inserted,
change.from,
change.removed.length,
);
break;
}
}
this.emit(this.values);
for (const observer of this.list_observers) {
this.call_list_observer(observer, change);
}
this.emit(this.elements);
}
private call_list_observer(
observer: (change: ListPropertyChangeEvent<T>) => void,
change: ListPropertyChangeEvent<T>,
): void {
try {
observer(change);
} catch (e) {
logger.error("Observer threw error.", e);
}
}
private replace_element_observers(new_elements: T[], from: number, amount: number): void {
let index = from;
const removed = this.element_observers.splice(
from,
amount,
...new_elements.map(element => {
const obj = {
index,
disposables: this.extract_observables!(element).map(observable =>
observable.observe(() => {
this.emit_list({
type: ListChangeType.Update,
updated: [element],
index: obj.index,
});
}),
),
};
index++;
return obj;
}),
);
for (const { disposables } of removed) {
for (const disposable of disposables) {
disposable.dispose();
}
}
while (index < this.element_observers.length) {
this.element_observers[index].index += index;
}
}
}

View File

@ -0,0 +1,5 @@
.hunt_optimizer_OptimizerView {
display: flex;
align-items: stretch;
overflow: hidden;
}

View File

@ -1,6 +1,7 @@
import { ResizableWidget } from "../../core/gui/ResizableWidget";
import { el } from "../../core/gui/dom";
import { WantedItemsView } from "./WantedItemsView";
import "./OptimizerView.css";
export class OptimizerView extends ResizableWidget {
private readonly wanted_items_view: WantedItemsView;
@ -11,12 +12,4 @@ export class OptimizerView extends ResizableWidget {
this.wanted_items_view = this.disposable(new WantedItemsView());
this.element.append(this.wanted_items_view.element);
}
resize(width: number, height: number): this {
super.resize(width, height);
this.wanted_items_view.resize(Math.min(200, width), height);
return this;
}
}

View File

@ -2,4 +2,16 @@
display: flex;
flex-direction: column;
align-items: stretch;
overflow: hidden;
}
.hunt_optimizer_WantedItemsView .hunt_optimizer_WantedItemsView_table_wrapper {
flex: 1;
width: 100%;
overflow: auto;
}
.hunt_optimizer_WantedItemsView .hunt_optimizer_WantedItemsView_table_wrapper table {
width: 100%;
}

View File

@ -1,36 +1,103 @@
import { ResizableWidget } from "../../core/gui/ResizableWidget";
import { el, Icon } from "../../core/gui/dom";
import "./WantedItemsView.css";
import { hunt_optimizer_store } from "../stores/HuntOptimizerStore";
import { Button } from "../../core/gui/Button";
import { Disposer } from "../../core/observable/Disposer";
import { Widget } from "../../core/gui/Widget";
import {
ListChangeType,
ListPropertyChangeEvent,
} from "../../core/observable/property/list/ListProperty";
import { WantedItemModel } from "../model";
import { NumberInput } from "../../core/gui/NumberInput";
export class WantedItemsView extends ResizableWidget {
export class WantedItemsView extends Widget {
private readonly tbody_element = el.tbody();
private readonly table_disposer = this.disposable(new Disposer());
constructor() {
super(el.div({ class: "hunt_optimizer_WantedItemsView" }));
this.element.append(el.h2({ text: "Wanted Items" }), el.table({}, this.tbody_element));
this.element.append(
el.h2({ text: "Wanted Items" }),
el.div(
{ class: "hunt_optimizer_WantedItemsView_table_wrapper" },
el.table({}, this.tbody_element),
),
);
hunt_optimizer_store.wanted_items.observe_list(this.update_table);
hunt_optimizer_store.wanted_items.observe_list(this.update_table, { call_now: true });
}
private update_table = (): void => {
this.tbody_element.append(
...hunt_optimizer_store.wanted_items.val.map(wanted_item => {
const remove_button = this.table_disposer.add(
new Button("", { icon_left: Icon.Remove }),
);
private update_table = (change: ListPropertyChangeEvent<WantedItemModel>): void => {
switch (change.type) {
case ListChangeType.Insertion:
{
const rows = change.inserted.map(this.create_row);
return el.tr(
{},
el.td({ text: wanted_item.amount.toString() }),
el.td({ text: wanted_item.item_type.name }),
el.td({}, remove_button.element),
);
}),
if (change.from >= this.tbody_element.childElementCount) {
this.tbody_element.append(...rows);
} else {
for (let i = change.from; i < change.to; i++) {
this.tbody_element.children[i].insertAdjacentElement(
"afterend",
rows[i - change.from],
);
}
}
}
break;
case ListChangeType.Removal:
for (let i = change.from; i < change.to; i++) {
this.tbody_element.children[change.from].remove();
}
break;
case ListChangeType.Replacement:
{
const rows = change.inserted.map(this.create_row);
for (let i = change.from; i < change.removed_to; i++) {
this.tbody_element.children[change.from].remove();
}
if (change.from >= this.tbody_element.childElementCount) {
this.tbody_element.append(...rows);
} else {
for (let i = change.from; i < change.inserted_to; i++) {
this.tbody_element.children[i].insertAdjacentElement(
"afterend",
rows[i - change.from],
);
}
}
}
break;
case ListChangeType.Update:
// TODO: update row
break;
}
};
private create_row = (wanted_item: WantedItemModel): HTMLTableRowElement => {
const amount_input = this.table_disposer.add(
new NumberInput(wanted_item.amount.val, { min: 1, step: 1 }),
);
this.table_disposer.add_all(
amount_input.value.bind_to(wanted_item.amount),
amount_input.value.observe(({ value }) => wanted_item.set_amount(value)),
);
const remove_button = this.table_disposer.add(new Button("", { icon_left: Icon.Remove }));
return el.tr(
{},
el.td({}, amount_input.element),
el.td({ text: wanted_item.item_type.name }),
el.td({}, remove_button.element),
);
};
}

View File

@ -2,9 +2,26 @@ import { ItemType } from "../../core/model/items";
import { DifficultyModel, SectionIdModel } from "../../core/model";
import { Episode } from "../../core/data_formats/parsing/quest/Episode";
import { Duration } from "luxon";
import { Property } from "../../core/observable/property/Property";
import { WritableProperty } from "../../core/observable/property/WritableProperty";
import { property } from "../../core/observable";
export class WantedItemModel {
constructor(readonly item_type: ItemType, readonly amount: number) {}
readonly item_type: ItemType;
readonly amount: Property<number>;
private readonly _amount: WritableProperty<number>;
constructor(item_type: ItemType, amount: number) {
this.item_type = item_type;
this._amount = property(amount);
this.amount = this._amount;
}
set_amount(amount: number): this {
this._amount.val = amount;
return this;
}
}
export class OptimalResultModel {

View File

@ -13,7 +13,7 @@ class HuntOptimizerPersister extends Persister {
wanted_items.map(
({ item_type, amount }): PersistedWantedItem => ({
itemTypeId: item_type.id,
amount,
amount: amount.val,
}),
),
);

View File

@ -35,7 +35,9 @@ class HuntOptimizerStore {
readonly wanted_items: ListProperty<WantedItemModel>;
readonly result: Property<OptimalResultModel | undefined>;
private readonly _wanted_items: WritableListProperty<WantedItemModel> = list_property();
private readonly _wanted_items: WritableListProperty<WantedItemModel> = list_property(
wanted_item => [wanted_item.amount],
);
private readonly _result: WritableProperty<OptimalResultModel | undefined> = property(
undefined,
);
@ -67,7 +69,7 @@ class HuntOptimizerStore {
// Initialize this set before awaiting data, so user changes don't affect this optimization
// run from this point on.
const wanted_items = new Set(
this.wanted_items.val.filter(w => w.amount > 0).map(w => w.item_type),
this.wanted_items.val.filter(w => w.amount.val > 0).map(w => w.item_type),
);
const methods = await hunt_method_stores.current.val.methods.promise;
@ -77,7 +79,7 @@ class HuntOptimizerStore {
const constraints: { [item_name: string]: { min: number } } = {};
for (const wanted of this.wanted_items.val) {
constraints[wanted.item_type.name] = { min: wanted.amount };
constraints[wanted.item_type.name] = { min: wanted.amount.val };
}
// Add a variable to the LP model per method per difficulty per section ID.