d3-view
Version:
View-model d3 plugin for building interactive data-driven web interfaces
380 lines (347 loc) • 9.98 kB
JavaScript
import { dispatch } from "d3-dispatch";
import { assign, isArray, isFunction, isObject, isString, pop } from "d3-let";
import viewModel from "../model/main";
import dashify from "../utils/dashify";
import dataAttributes from "../utils/data";
import map from "../utils/map";
import maybeJson from "../utils/maybeJson";
import sel from "../utils/sel";
import asSelect from "../utils/select";
import Cache from "./cache";
import createDirective from "./directive";
import "./selection";
import base from "./transition";
export const protoView = {
doMount(el) {
return asView(this, el);
}
};
// prototype for both views and components
const protoComponent = {
//
// hooks
render() {},
childrenMounted() {},
mounted() {},
destroy() {},
//
// Mount the component into an element
// If this component is already mounted, or it is mounting, it does nothing
mount(el, data) {
if (mounted(this)) return;
const sel = asSelect(el);
el = sel.node();
if (!el) {
this.logWarn(
`element not defined, pass an identifier or an HTMLElement object`
);
return Promise.resolve(this);
}
// set the owner document
this.ownerDocument = el.ownerDocument;
//
const directives = sel.directives(this);
let props = assign(dataAttributes(directives.attrs), sel.datum(), data),
extra = maybeJson(pop(props, "props")),
value,
model,
parentModel;
if (isObject(extra)) props = assign(extra, props);
else if (this.parent && this.parent.props[extra])
props = assign({}, this.parent.props[extra], props);
// fire mount event
this.events.call("mount", undefined, this, el, props);
// pick parent model & props
if (this.parent) {
//
parentModel = this.parent.model;
model = pop(props, "model");
if (model && !model.$child) model = parentModel[model];
if (!model) model = parentModel.$new();
else model = model.$child();
//
// Get props from parent view
Object.keys(this.props).forEach(key => {
value = this.parent.props[props[key]];
if (value === undefined) {
value = maybeJson(props[key]);
if (value !== undefined) props[key] = value;
// default value if available
else if (this.props[key] !== undefined) props[key] = this.props[key];
} else props[key] = value;
});
//
// get props object from parent if props is defined
if (isString(extra)) props = assign({}, this.parent.props[extra], props);
} else {
props = assign(this.props, props);
model = viewModel();
}
// add reactive model properties
Object.keys(this.model).forEach(key => {
value = pop(props, key);
if (value !== undefined) {
if (isString(value) && parentModel && parentModel.$isReactive(value))
model.$connect(key, value, parentModel);
else model.$set(key, maybeJson(value));
} else if (model[key] === undefined) model.$set(key, this.model[key]);
});
this.model = bindView(this, model);
//
// create the new element from the render function
if (!props.id) props.id = model.uid;
this.props = props;
//
return this.doMount(el);
},
createElement(tag, props) {
const doc = this.ownerDocument || document;
const sel = this.select(doc.createElement(tag));
if (props) {
sel.attr("id", this.props.id);
if (this.props.class) sel.classed(this.props.class, true);
}
return sel;
},
doMount(el) {
let newEl;
try {
newEl = this.render(el);
} catch (error) {
newEl = Promise.reject(error);
}
if (!newEl || !newEl.then) newEl = Promise.resolve(newEl);
return newEl
.then(element => compile(this, el, element))
.catch(exc => error(this, el, exc));
},
use(plugin) {
if (isObject(plugin)) plugin.install(this);
else plugin(this);
return this;
},
addComponent(name, obj) {
name = dashify(name);
var component = createComponent(name, obj);
this.components.set(name, component);
return component;
},
addDirective(name, obj) {
name = dashify(name);
var directive = createDirective(obj);
this.directives.set(name, directive);
return directive;
}
};
// factory of View and Component constructors
export const createComponent = (name, o, coreDirectives, coreComponents) => {
if (isFunction(o)) o = { render: o };
var obj = assign({}, o),
classComponents = extendComponents(new Map(), pop(obj, "components")),
classDirectives = extendDirectives(new Map(), pop(obj, "directives")),
model = pop(obj, "model"),
props = pop(obj, "props");
function Component(options) {
var parent = pop(options, "parent"),
components = map(parent ? parent.components : coreComponents),
directives = map(parent ? parent.directives : coreDirectives),
events = parent
? parent.events
: dispatch(
"message",
"created",
"mount",
"mounted",
"error",
"directive-refresh"
),
cache = parent ? parent.cache : new Cache();
classComponents.forEach((comp, key) => {
components.set(key, comp);
});
classDirectives.forEach((comp, key) => {
directives.set(key, comp);
});
extendComponents(components, pop(options, "components"));
extendDirectives(directives, pop(options, "directives"));
Object.defineProperties(this, {
name: {
get() {
return name;
}
},
components: {
get() {
return components;
}
},
directives: {
get() {
return directives;
}
},
parent: {
get() {
return parent;
}
},
root: {
get() {
return parent ? parent.root : this;
}
},
cache: {
get() {
return cache;
}
},
uid: {
get() {
return this.model.uid;
}
},
events: {
get() {
return events;
}
}
});
this.props = asObject(props, pop(options, "props"));
this.model = asObject(model, pop(options, "model"));
this.events.call("created", undefined, this);
}
Component.prototype = assign({}, base, protoComponent, obj);
function component(options) {
return new Component(options);
}
component.prototype = Component.prototype;
return component;
};
// Used by both Component and view
export const extendComponents = (container, components) => {
map(components).forEach((obj, key) => {
key = dashify(key);
container.set(key, createComponent(key, obj, protoComponent));
});
return container;
};
export const extendDirectives = (container, directives) => {
map(directives).forEach((obj, key) => {
key = dashify(key);
container.set(key, createDirective(obj));
});
return container;
};
//
// Finalise the binding between the view and the model
// inject the model into the view element
// call the mounted hook and can return a Promise
export const asView = (vm, element) => {
Object.defineProperty(sel(vm), "el", {
get: function() {
return element;
}
});
// Apply model to element and mount
return vm
.select(element)
.view(vm)
.mount()
.then(() => vmMounted(vm));
};
export const mounted = vm => {
if (vm.isMounted === undefined) {
vm.isMounted = false;
return false;
} else if (vm.isMounted) {
vm.logWarn(`component already mounted`);
} else {
vm.isMounted = true;
// invoke mounted component hook
vm.mounted();
// invoke the view mounted events
vm.events.call("mounted", undefined, vm);
}
return true;
};
// Internals
//
// Component/View mounted
// =========================
//
// This function is called when a component/view has all its children added
const vmMounted = vm => {
var parent = vm.parent;
vm.childrenMounted();
if (parent && !parent.isMounted) {
const event = `mounted.${vm.uid}`;
vm.events.on(event, cm => {
if (cm === parent) {
vm.events.on(event, null);
mounted(vm);
}
});
} else mounted(vm);
return vm;
};
// Compile a component model
// This function is called once a component has rendered the component element
const compile = (cm, origEl, element) => {
if (isString(element)) {
const props = Object.keys(cm.props).length ? cm.props : null;
element = cm.viewElement(element, props, origEl.ownerDocument);
}
element = asSelect(element);
const size = element.size();
if (!size)
throw new Error(
"render() must return a single HTML node. It returned nothing!"
);
else if (size !== 1) cm.logWarn("render() must return a single HTML node");
element = element.node();
//
// mark the original element as component
origEl.__d3_component__ = true;
// Insert before the component element
if (origEl.parentNode) origEl.parentNode.insertBefore(element, origEl);
// remove the component element
cm.select(origEl).remove();
//
return asView(cm, element);
};
// Invoked when a component cm has failed to render
const error = (cm, origEl, exc) => {
cm.logWarn(`failed to render due to the unhandled exception reported below`);
cm.logError(exc);
cm.events.call("error", undefined, cm, origEl, exc);
return cm;
};
const asObject = (value, opts) => {
if (isFunction(value)) value = value();
if (isArray(value))
value = value.reduce((o, key) => {
o[key] = undefined;
return o;
}, {});
return assign({}, value, opts);
};
const bindView = (view, model) => {
Object.defineProperties(model, {
$$view: {
get() {
return view;
}
},
$$name: {
get() {
return view.name;
}
},
props: {
get() {
return view.props;
}
}
});
return model;
};