web.script.topicmap_viewmodel.js Maven / Gradle / Ivy
The newest version!
/**
* A topicmap viewmodel that is attached to the database. There are methods for:
* - loading a topicmap from DB
* - manipulating the topicmap by e.g. adding/removing topics and associations
*
* @param config an object with 2 properties:
* "is_writable" (boolean) -- if true changes to this model are written to be DB.
* "customizers" (array of viewmodel customer instances) -- the registered viewmodel customizers.
* @param restc the REST client used for performing the DB updates.
*
* ### TODO: introduce common base class for TopicmapViewmodel and GeomapViewmodel (see dm4-geomaps module)
*/
function TopicmapViewmodel(topicmap_id, config, restc) {
var self = this
var info // The underlying Topicmap topic (a Topic object)
var topics = {} // topics of this topicmap (key: topic ID, value: TopicViewmodel object)
var assocs = {} // associations of this topicmap (key: association ID, value: AssociationViewmodel object)
// Translation model
this.trans_x, this.trans_y // topicmap translation (in pixel)
// Selection model
this.selected_object_id = -1 // ID of the selected topic or association, or -1 for no selection
this.is_topic_selected // true indicates topic selection, false indicates association selection
// only evaluated if there is a selection (selected_object_id != -1)
this.background_image // JavaScript Image object
load()
// ------------------------------------------------------------------------------------------------------ Public API
this.get_id = function() {
return topicmap_id
}
this.get_renderer_uri = function() {
return info.get("dm4.topicmaps.topicmap_renderer_uri")
}
// ---
this.get_topic = function(id) {
return get_topic(id)
}
this.get_association = function(id) {
return get_association(id)
}
// ---
this.iterate_topics = function(visitor_func) {
iterate_topics(visitor_func)
}
this.iterate_associations = function(visitor_func) {
iterate_associations(visitor_func)
}
// ---
/**
* @param topic a domain topic (has "id", "type_uri", "value", "childs" properties).
*
* @return The topic viewmodel that represents what is about to be added to the view (a TopicViewmodel object).
* This is either a new viewmodel (in case the domain topic was not yet contained in the topicmap) or
* a modified viewmodel (in case the domain topic is already contained in the topicmap but was hidden).
* If the domain topic is already contained in the topicmap and is visible already nothing is returned.
*/
this.add_topic = function(topic, x, y) {
var _topic = topics[topic.id]
if (!_topic) {
// update memory
var view_props = default_view_props()
_topic = add_topic(topic, view_props)
// update DB
if (is_writable()) {
restc.add_topic_to_topicmap(topicmap_id, topic.id, view_props)
}
//
return _topic
} else if (!_topic.visibility) {
// update memory
_topic.set_visibility(true)
// update DB
if (is_writable()) {
restc.set_topic_visibility(topicmap_id, topic.id, true)
}
//
return _topic
} else {
// topic already visible in topicmap
}
function default_view_props() {
var view_props = {
"dm4.topicmaps.x": x,
"dm4.topicmaps.y": y,
"dm4.topicmaps.visibility": true
}
invoke_customizers("enrich_view_properties", [topic, view_props])
return view_props
}
}
/**
* @param assoc a domain association (has "id", "type_uri", "role_1", "role_2" properties).
*/
this.add_association = function(assoc) {
try {
var _assoc = assocs[assoc.id]
if (!_assoc) {
// update memory
_assoc = add_association(assoc) // throws exception
// update DB
if (is_writable()) {
restc.add_association_to_topicmap(topicmap_id, assoc.id)
}
//
return _assoc
} else {
// association already in topicmap
}
} catch (e) {
alert(e)
}
}
// ---
this.set_view_properties = function(topic_id, view_props) {
var topic = get_topic(topic_id)
// update memory
topic.set_view_properties(view_props)
// update DB
if (is_writable()) {
restc.set_view_properties(topicmap_id, topic_id, view_props)
}
}
this.set_topic_position = function(id, x, y) {
var topic = topics[id]
// update memory
topic.set_position(x, y)
// update DB
if (is_writable()) {
restc.set_topic_position(topicmap_id, id, x, y)
}
}
// ---
this.hide_topic = function(id) {
var topic = topics[id]
// update memory
topic.set_visibility(false)
// update DB
if (is_writable()) {
restc.set_topic_visibility(topicmap_id, id, false)
}
}
this.hide_association = function(id) {
var assoc = assocs[id]
// update memory
assoc.delete() // Note: a hidden association is removed from the viewmodel, just like a deleted association
// update DB
if (is_writable()) {
restc.remove_association_from_topicmap(topicmap_id, id)
}
}
// ---
/**
* @param topic a domain topic (has "id", "type_uri", "value", "childs" properties).
*/
this.update_topic = function(topic) {
var t = topics[topic.id]
if (t) {
// update memory
t.update(topic)
// Note: no DB update here. A topic update doesn't affect the view data.
//
if (t.visibility) {
return t
}
}
}
/**
* @param assoc a domain association (has "id", "type_uri", "role_1", "role_2" properties).
*/
this.update_association = function(assoc) {
var a = assocs[assoc.id]
if (a) {
// update memory
a.update(assoc)
// Note: no DB update here. An association update doesn't affect the view data.
//
return a
}
}
// ---
this.delete_topic = function(id) {
var topic = topics[id]
if (topic) {
// update memory
topic.delete()
// Note: no DB update here. The persisted view is already up-to-date (view data is stored in association).
}
}
this.delete_association = function(id) {
var assoc = assocs[id]
if (assoc) {
// update memory
assoc.delete()
// Note: no DB update here. The persisted view is already up-to-date (view data is stored in association).
}
}
// ---
this.set_topic_selection = function(topic_id) {
var topic = topics[topic_id]
if (!topic || !topic.visibility) {
throw "TopicmapViewmodelError: set_topic_selection(" + topic_id + ") failed. " +
"Topic not in topicmap or not visible."
}
// update memory
this.selected_object_id = topic_id
this.is_topic_selected = true
// Note: no DB update here. The selection is not yet persisted.
}
this.set_association_selection = function(assoc_id) {
var assoc = assocs[assoc_id]
if (!assoc) {
throw "TopicmapViewmodelError: set_association_selection(" + assoc_id + ") failed. " +
"Association not in topicmap."
}
// update memory
this.selected_object_id = assoc_id
this.is_topic_selected = false
// Note: no DB update here. The selection is not yet persisted.
}
this.reset_selection = function() {
// update memory
this.selected_object_id = -1
// Note: no DB update here. The selection is not yet persisted.
}
// ---
/**
* @param id A topic ID or an association ID
*/
this.is_selected = function(id) {
return this.has_selection() && this.selected_object_id == id
}
/**
* Returns true if there is a selection.
*/
this.has_selection = function() {
return this.selected_object_id != -1
}
/**
* Returns the ID of the selected topic, provided a topic is selected, false otherwise.
*/
/* ### this.get_selected_topic_id = function() {
return this.has_selection() && this.is_topic_selected && this.selected_object_id
} */
/**
* Precondition: there is a selection.
*
* @return an object with "x" and "y" properties.
*/
this.get_selection_pos = function() {
if (this.is_topic_selected) {
var topic = get_topic(this.selected_object_id)
return {x: topic.x, y: topic.y}
} else {
var assoc = get_association(this.selected_object_id)
var topic_1 = assoc.get_topic_1()
var topic_2 = assoc.get_topic_2()
return {
x: (topic_1.x + topic_2.x) / 2,
y: (topic_1.y + topic_2.y) / 2
}
}
}
// ---
this.set_translation = function(trans_x, trans_y) {
// update memory
this.trans_x = trans_x
this.trans_y = trans_y
// update DB
if (is_writable()) {
restc.set_topicmap_translation(topicmap_id, trans_x, trans_y)
}
}
this.translate_by = function(dx, dy) {
// update memory
this.trans_x += dx
this.trans_y += dy
// update DB
//
// Note: the DB is not updated here. This works around the fact that the canvas view does not have its own
// translation model. Instead it updates the topicmap viewmodel repeatedly while moving the canvas. We don't
// want create many DB update requests while a canvas drag.
// ### TODO: consider equipping the canvas view with a translation model.
}
// ---
this.get_topic_associations = function(topic_id) {
var cas = []
iterate_associations(function(ca) {
if (ca.is_player_topic(topic_id)) {
cas.push(ca)
}
})
return cas
}
// ---
this.create_cluster = function(ca) {
return new ClusterViewmodel(ca)
}
this.set_cluster_position = function(cluster) {
// update memory
cluster.iterate_topics(function(ct) {
get_topic(ct.id).set_position(ct.x, ct.y)
})
// update DB
if (is_writable()) {
restc.set_cluster_position(topicmap_id, cluster_coords())
}
function cluster_coords() {
var coord = []
cluster.iterate_topics(function(ct) {
coord.push({
topic_id: ct.id,
x: ct.x,
y: ct.y
})
})
return coord
}
}
// ---
this.draw_background = function(ctx) {
if (this.background_image) {
ctx.drawImage(this.background_image, 0, 0)
}
}
// ----------------------------------------------------------------------------------------------- Private Functions
function load() {
var topicmap = restc.get_topicmap(topicmap_id)
info = new Topic(topicmap.info)
//
init_topics()
init_associations()
init_translation()
init_background_image()
function init_topics() {
for (var i = 0, topic; topic = topicmap.topics[i]; i++) {
add_topic(topic, topic.view_props)
}
}
function init_associations() {
var error
for (var i = 0, assoc; assoc = topicmap.assocs[i]; i++) {
try {
add_association(assoc) // throws exception
} catch (e) {
if (!error) {
error = "WARNING: topicmap " + topicmap_id + " will be displayed incomplete"
}
error += "\n\n" + e
}
}
if (error) {
alert(error)
}
}
// ---
function init_translation() {
var trans = info.get("dm4.topicmaps.state").get("dm4.topicmaps.translation")
self.trans_x = trans.get("dm4.topicmaps.translation_x")
self.trans_y = trans.get("dm4.topicmaps.translation_y")
}
function init_background_image() {
var file = info.get("dm4.files.file")
if (file) {
var path = file.get("dm4.files.path")
var image_url = "/filerepo/" + encodeURIComponent(path)
self.background_image = dm4c.create_image(image_url)
}
}
}
// ---
/**
* @return A TopicViewmodel
*/
function get_topic(id) {
return topics[id]
}
/**
* @return A AssociationViewmodel
*/
function get_association(id) {
return assocs[id]
}
// ---
function iterate_topics(visitor_func) {
for (var id in topics) {
visitor_func(topics[id])
}
}
function iterate_associations(visitor_func) {
for (var id in assocs) {
visitor_func(assocs[id])
}
}
// ---
/**
* @param topic a domain topic (has "id", "type_uri", "value", "childs" properties).
*/
function add_topic(topic, view_props) {
return topics[topic.id] = new TopicViewmodel(topic, view_props)
}
/**
* @param assoc a domain association (has "id", "type_uri", "role_1", "role_2" properties).
*/
function add_association(assoc) {
var topic_1 = get_topic(assoc.role_1.topic_id)
var topic_2 = get_topic(assoc.role_2.topic_id)
if (topic_1 && topic_2) {
if (topic_1.visibility && topic_2.visibility) {
return assocs[assoc.id] = new AssociationViewmodel(assoc)
}
throw "Association " + assoc.id + " can't be displayed because a topic is not visible\n(topic IDs=" +
assoc.role_1.topic_id + ", " + assoc.role_2.topic_id + ")"
}
throw "Association " + assoc.id + " can't be displayed because a topic is missing\n(topic IDs=" +
assoc.role_1.topic_id + ", " + assoc.role_2.topic_id + ")"
}
// ---
function is_writable() {
return config.is_writable
}
// === Customization ===
/**
* @param args array of arguments
*/
function invoke_customizers(func_name, args) {
for (var i = 0, customizer; customizer = config.customizers[i]; i++) {
customizer[func_name] && customizer[func_name].apply(undefined, args)
}
}
// ------------------------------------------------------------------------------------------------- Private Classes
/**
* @param topic a domain topic (has "id", "type_uri", "value", "childs" properties).
*/
function TopicViewmodel(topic, view_props) {
var _self = this // Note: we must not override the outer "self" scope
this.id = topic.id
// standard view properties
this.x = view_props["dm4.topicmaps.x"]
this.y = view_props["dm4.topicmaps.y"]
this.visibility = view_props["dm4.topicmaps.visibility"]
// enable access to custom view properties
this.view_props = view_props
init(topic)
// ---
this.set_visibility = function(visibility) {
this.visibility = visibility
//
if (!visibility) {
reset_selection_conditionally()
}
}
this.set_position = function(x, y) {
this.x = x
this.y = y
}
this.set_view_properties = function(view_props) {
// Note: this has a side effect on the corresponding TopicView object as it holds
// a reference to this topic's view_props object (instead of creating another one).
// So the CanvasView must not care about updating the TopicView object.
js.copy(view_props, this.view_props)
}
/**
* @param topic a domain topic (has "id", "type_uri", "value", "childs" properties).
*/
this.update = function(topic) {
init(topic)
}
this.delete = function() {
// Note: all topic references are deleted already
delete topics[topic.id]
reset_selection_conditionally()
}
// ---
function init(topic) {
_self.type_uri = topic.type_uri
_self.label = topic.value
_self.childs = topic.childs
}
function reset_selection_conditionally() {
if (self.is_topic_selected && self.selected_object_id == topic.id) {
self.reset_selection()
}
}
}
/**
* @param assoc a domain association (has "id", "type_uri", "value", "role_1", "role_2" properties).
*/
function AssociationViewmodel(assoc) {
var _self = this // Note: we must not override the outer "self" scope
this.id = assoc.id
this.topic_id_1 = assoc.role_1.topic_id
this.topic_id_2 = assoc.role_2.topic_id
init(assoc)
// ---
this.get_topic_1 = function() {
return get_topic(this.topic_id_1)
}
this.get_topic_2 = function() {
return get_topic(this.topic_id_2)
}
// ---
this.is_player_topic = function(topic_id) {
return topic_id == this.topic_id_1 || topic_id == this.topic_id_2
}
this.get_other_topic = function(topic_id) {
if (topic_id == this.topic_id_1) {
return this.get_topic_2()
} else if (topic_id == this.topic_id_2) {
return this.get_topic_1()
} else {
throw "AssociationViewmodelError: topic " + topic_id + " is not a player in " + JSON.stringify(this)
}
}
// ---
/**
* @param assoc a domain association (has "id", "type_uri", "role_1", "role_2" properties).
*/
this.update = function(assoc) {
init(assoc)
}
this.delete = function() {
delete assocs[assoc.id]
reset_selection_conditionally()
}
// ---
function init(assoc) {
_self.type_uri = assoc.type_uri
_self.label = assoc.value
}
function reset_selection_conditionally() {
if (!self.is_topic_selected && self.selected_object_id == assoc.id) {
self.reset_selection()
}
}
}
function ClusterViewmodel(ca) {
var cts = [] // array of TopicView
add_to_cluster(ca.get_topic_1())
this.iterate_topics = function(visitor_func) {
for (var i = 0, ct; ct = cts[i]; i++) {
visitor_func(ct)
}
}
function add_to_cluster(ct) {
if (is_in_cluster(ct)) {
return
}
//
cts.push(ct)
var cas = self.get_topic_associations(ct.id)
for (var i = 0, ca; ca = cas[i]; i++) {
add_to_cluster(ca.get_other_topic(ct.id))
}
}
function is_in_cluster(ct) {
return js.includes(cts, function(cat) {
return cat.id == ct.id
})
}
}
}