Files
P42_UI/app/views/system/admin/explorer/DataExplorerView.js
T
2025-08-27 07:03:09 +00:00

406 lines
16 KiB
JavaScript

/**
* n00014p5
*/
class DataExplorerView extends EICDomContent {
options = {
entity: {
width: 140,
height: 70,
gap: 20,
dragging: false,
current: null
},
map: {
position: { x: 0, y: 0 },
size: { width: 0, height: 0 },
dragging: false,
},
scale: {
current: 1.0,
factor: 0.085,
min: 0.5,
max: 4
},
guides: true
}
filtersList = [];
DOMContentLoaded(options) {
for(let model in options.models) this[model] = options.models[model];
this.components = ui.eicfy(this.el);
this.q = this.components.find(c => c.el.name == 'q');
this.q.onQuery = this.onQuery.bind(this);
this.attributes = this.components.find(c => c.el.name == 'attributes');
this.attributes.activeEditor = DataExplorerFilter;
this.attributes.onEditorOpened = this.fillFilters.bind(this);
this.attributes.el.addEventListener( 'change' , this.onQuery.bind(this));
this.results = this.find('.results');
this.viewboxPosition = {x: 0, y: 0};
this.map = this.find('.results svg');
this.map.addEventListener('wheel', this.onMapScroll.bind(this));
this.map.addEventListener('mousedown', this.onMapDragStart.bind(this));
this.map.addEventListener('mousemove', this.onMapDragMove.bind(this));
this.mouseUpHandler = this.onMapDragStop.bind(this);
this.entityUpHandler = this.onEntityDragStop.bind(this);
// observe map viewport resizing
new ResizeObserver(this.onMapResize.bind(this)).observe(this.find('.results '))
this.ml.filters().then(payload => { this.filtersList = payload; })
}
clearMap() {
this.map.innerHTML = '';
this.entities = [];
}
clearDetail() { this.find('.detail').innerHTML = ''; }
fillFilters(editor) {
let select = editor.el.querySelector('select');
for(let field of this.filtersList) {
let option = ui.create(`<option value="${field.name}">${field.name}</option>`)
select.append(option)
}
}
fillMap(data) {
this.clearMap();
this.clearDetail();
this.viewboxSize = {
x: this.find('.results').clientWidth,
y: this.find('.results').clientHeight
};
this.viewboxStartPosition = {x: 0, y: 0};
this.options.map.dragging = false;
for(let entity of data.entities) this.addEntity(entity);
for(let relation of data.relations) this.addRelation(relation);
this.reorderMap();
}
addEntity(entity) {
const width = this.options.entity.width;
const height = this.options.entity.height;
const gap = this.options.entity.gap;
let template = this.ml.getType(entity._entity_type);
let index = this.entities.length;
let content = ui.create(
`<svg class="entity" x="${gap + (width + gap) * index}" y="30" width="${width}" text-anchor="middle" data-id="${entity.id}">
<rect class="bg" width="${width}" height="${height}" x="2" y="5" rx="10"></rect>
<text class="icon" x="${width / 2}" y="32">${template.icon}</text>
<text class="title" x="${width / 2}" y="50">${entity._entity_label}</text>
<text class="type" x="${width / 2}" y="65">${template.label}</text>
</svg>`
);
content.addEventListener('click', this.onEntitySelect.bind(this));
content.addEventListener('mousedown', this.onEntityDragStart.bind(this));
content.addEventListener('mousemove', this.onEntityMove.bind(this));
this.entities.push({ el: content, data: entity, lines: [] });
this.map.append(content);
}
addRelation(relation) {
let o1 = this.entities.find(item => item.data.id == relation.source);
let o2 = this.entities.find(item => item.data.id == relation.target);
if(!o1 || !o2) return;
let line = ui.create(
`<svg><line class="relation"
x1="${o1.el.getAttribute('x')}" y1="${o1.el.getAttribute('y')}"
x2="${o2.el.getAttribute('x')}" y2="${o2.el.getAttribute('y')}"
data-source="${o1.data.id}" data-target="${o2.data.id}" /></svg>`
).querySelector('line');
this.map.prepend(line);
o1.lines.push(line)
o2.lines.push(line)
}
reorderMap() {
let a = (Math.PI * 2) / this.entities.length;
let r = (this.options.entity.width) * Math.log(this.entities.length);
let i = 0;
let center = { x: this.viewboxSize.x / 2, y: this.viewboxSize.y / 2 }
for(let entity of this.entities) {
let pos = {
x: center.x + (Math.cos(a * i - Math.PI * .5) * r) - (this.options.entity.width / 2),
y: center.y + (Math.sin(a * i - Math.PI * .5) * r) - (this.options.entity.height / 2)
}
entity.el.setAttribute('x', pos.x)
entity.el.setAttribute('y', pos.y)
for(let line of entity.lines) {
if(line.getAttribute("data-source") == entity.data.id) {
line.setAttribute("x1", pos.x + (this.options.entity.width / 2));
line.setAttribute("y1", pos.y + (this.options.entity.height / 2));
} else {
line.setAttribute("x2", pos.x + (this.options.entity.width / 2));
line.setAttribute("y2", pos.y + (this.options.entity.height / 2));
}
}
i++;
}
if(this.entities.length > 0 && this.options.guides) {
let guide = ui.create(`<svg><circle stroke="#888" fill="none" stroke-width="1" stroke-dasharray="5,5" cx="${center.x}" cy="${center.y}" r="${r}" /></svg>`)
this.map.prepend(guide.querySelector('circle'));
}
}
setEntityDetail(entity) {
let template = this.ml.getType(entity._entity_type);
let el = ui.create(`<article eiccard primary>
<header><h1>${entity._entity_label}</h1><h2 small>${template.label}</h2></header>
<section>
<div eicdatagrid footer="hidden"></div>
</section>
<footer></footer>
</article>`);
let footer = el.querySelector('footer');
let props = new DataGrid(el.querySelector('[eicdatagrid]'), {
headers: [
{label: 'property', filter:'text'},
{label: 'value', filter:'text'},
],
height: '270px'
});
this.setEntityProp(props, entity, 'id', null, entity._entity_type)
for(let prop in entity.data) {
//if(['_entity_type', '_entity_label'].indexOf(prop) == -1) {
this.setEntityProp(props, entity.data, prop, null, entity._entity_type)
//}
}
let lines = this.entities.find(item => item.data.id == entity.id).lines;
if(lines.length > 0) {
footer.append(ui.create(`<div xsmall>Relates locally to:</small>`))
let ul = ui.create(`<ul bulleted small></ul>`)
footer.append(ul);
for(let line of lines) {
let other = this.entities.find(item => (item.data.id == line.getAttribute('data-target') && line.getAttribute('data-source') == entity.id) || (item.data.id == line.getAttribute('data-source') && line.getAttribute('data-target') == entity.id))
let template = this.ml.getType(other.data._entity_type);
let li = ui.create(`<li data-id="${other.data.id}"><a href="#">${other.data._entity_label} (${template.label})</a></li>`);
li.addEventListener('click', this.onEntitySelect.bind(this))
ul.append(li)
}
}
this.clearDetail();
this.find('.detail').append(el);
}
setEntityProp(table, parent, prop, prefix, type) {
if(typeof parent[prop] !== 'object') {
let allowSearch = ['id', 'email', 'euLogin', 'pic'].indexOf(prop) != -1;
let value = allowSearch ?
`<a data-query="${type}:${prop}:${parent[prop]}" href="#">${parent[prop]}</a>` :
parent[prop];
let row = table.addRow('', [ (prefix ? prefix + '.': '') + prop, value ]);
if(allowSearch) {
row.querySelector('.cell:nth-child(3) a').addEventListener('click', this.onQueryEntity.bind(this))
}
} else {
for(let subprop in parent[prop])
this.setEntityProp(table,parent[prop], subprop, (prefix ? prefix + '.': '') + prop, type)
}
}
onQuery(event) {
if(this.q.value.trim() == '' && this.attributes.value.length == 0) return;
let query = { freeText: this.q.value }
this.attributes.value.map(val => {
let parts = val.split('=');
if(parts.length > 1) query[parts[0]] = parts[1];
})
this.ml.search(query).then( this.fillMap.bind(this));
}
onQueryEntity(event) {
event.stopPropagation();
event.preventDefault();
this.q.value = event.currentTarget.dataset.query;
this.onQuery(event);
}
onEntityDragStart(event) {
event.stopPropagation();
event.preventDefault();
this.options.entity.current = event.currentTarget
this.mouseStartPosition = { x: event.pageX, y: event.pageY }
this.entityStartPosition = { x: Number.parseFloat(this.options.entity.current.getAttribute("x")), y: Number.parseFloat(this.options.entity.current.getAttribute("y")) }
window.addEventListener("mouseup", this.entityUpHandler);
this.options.entity.dragging = true;
}
onEntityMove(event) {
event.stopPropagation();
event.preventDefault();
this.mousePosition = { x: event.offsetX, y: event.offsetY };
if(this.options.entity.dragging) {
this.options.entity.current.setAttribute("x", this.entityStartPosition.x - (this.mouseStartPosition.x - event.pageX) * this.options.scale.current)
this.options.entity.current.setAttribute("y", this.entityStartPosition.y - (this.mouseStartPosition.y - event.pageY) * this.options.scale.current)
let entity = this.entities.find(item => item.data.id == (this.options.entity.current.getAttribute("data-id")))
let pos = { x: Number.parseFloat(this.options.entity.current.getAttribute("x")), y: Number.parseFloat(this.options.entity.current.getAttribute("y")) }
for(let line of entity.lines) {
if(line.getAttribute("data-source") == entity.data.id) {
line.setAttribute("x1", pos.x + (this.options.entity.width / 2));
line.setAttribute("y1", pos.y + (this.options.entity.height / 2));
} else {
line.setAttribute("x2", pos.x + (this.options.entity.width / 2));
line.setAttribute("y2", pos.y + (this.options.entity.height / 2));
}
}
}
}
onEntityDragStop(event) {
event.stopPropagation();
event.preventDefault();
window.removeEventListener("mouseup", this.entityUpHandler);
this.options.entity.dragging = false;
}
onEntitySelect(event) {
event.stopPropagation();
event.preventDefault();
let selected = this.entities.find(item => item.el.dataset.id == event.currentTarget.dataset.id);
this.entities.forEach(item => {
item.el.classList.remove('selected');
item.el.classList.remove('related');
item.lines.forEach(line => line.classList.remove('related'))
});
selected.el.classList.add('selected');
selected.lines.forEach(line => {
line.classList.add('related');
let other = this.entities.find(item => (item.data.id == line.getAttribute('data-target') && line.getAttribute('data-source') == selected.data.id) || (item.data.id == line.getAttribute('data-source') && line.getAttribute('data-target') == selected.data.id));
other.el.classList.add('related');
});
this.setEntityDetail(selected.data);
}
onMapScroll(event) {
event.stopPropagation();
event.preventDefault();
let scale = 1. + (event.deltaY < 0 ? - this.options.scale.factor : this.options.scale.factor);
if((this.options.scale.current * scale < this.options.scale.max) && (this.options.scale.current * scale > this.options.scale.min))
{
let pos = {
x: (this.mousePosition.x * this.options.scale.current) + this.viewboxPosition.x,
y: (this.mousePosition.y * this.options.scale.current) + this.viewboxPosition.y
}
this.viewboxPosition.x = (this.viewboxPosition.x - pos.x) * scale + pos.x;
this.viewboxPosition.y = (this.viewboxPosition.y - pos.y) * scale + pos.y;
this.options.scale.current *= scale;
this.setViewbox();
}
}
onMapDragStart(event) {
this.mouseStartPosition = { x: event.pageX, y: event.pageY }
this.viewboxStartPosition = { x: this.viewboxPosition.x, y: this.viewboxPosition.y }
this.options.map.dragging = true;
window.addEventListener("mouseup", this.mouseUpHandler);
}
onMapDragMove(event) {
this.mousePosition = { x: event.offsetX, y: event.offsetY };
if(this.options.map.dragging) {
this.viewboxPosition.x = this.viewboxStartPosition.x + (this.mouseStartPosition.x - event.pageX) * this.options.scale.current;
this.viewboxPosition.y = this.viewboxStartPosition.y + (this.mouseStartPosition.y - event.pageY) * this.options.scale.current;
this.setViewbox();
}
}
onMapDragStop(event) {
window.removeEventListener("mouseup", this.mouseUpHandler);
this.options.map.dragging = false;
}
onMapResize(event) {
this.viewboxSize = {
x: this.find('.results').clientWidth,
y: this.find('.results').clientHeight
};
this.setViewbox();
}
setViewbox() { this.map.setAttribute("viewBox", this.viewboxPosition.x + " " + this.viewboxPosition.y + " " + (this.viewboxSize.x * this.options.scale.current) + " " + (this.viewboxSize.y * this.options.scale.current)); }
}
class DataExplorerFilter extends SelectEditor {
_template = `<div class="eicui-select-editor cols-2">
<select eicselect placeholder="select a field"></select><input eicinput data-type="ignore" placeholder="set a value" />
</div>`;
open() {
super.open();
this.input = this.components.find(c => c.el.nodeName == 'INPUT')
this.select = this.components.find(c => c.el.nodeName == 'SELECT')
this.select.clear();
}
initEvents() {
this.el.querySelector('input').addEventListener('keypress', this.onKeyPressed.bind(this));
this.el.querySelector('select').addEventListener('change', this.onFieldChange.bind(this) );
}
onFieldChange(event) {
this.input.disabled = this.select.value.length == 0;
}
get valid() { return super.valid && this.el.querySelector('select').value != ''; }
get value() { return {value: this.el.querySelector('select').value + "=" + this.el.querySelector('input').value, label: this.el.querySelector('select').value + ' is "' + this.el.querySelector('input').value + '"' } }
}
app.registerClass('DataExplorerView', DataExplorerView);