WIP upgraded index, slash annotation editor

This commit is contained in:
Ruben van de Ven 2022-06-08 17:26:30 +02:00
parent ab9c66794c
commit 5fa5096c44
5 changed files with 382 additions and 124 deletions

View File

@ -727,6 +727,9 @@ class Tag(NodeMixin):
def __repr__(self):
return f"<svganim.strokes.Tag {self.id}>"
def __str__(self):
return RenderTree(self).by_attr('name')
def get_color(self):
if self.color is None and self.parent is not None:
@ -741,12 +744,18 @@ class Tag(NodeMixin):
if t.id == tag_id:
return t
return None
def toJson(self) -> str:
return JsonExporter(indent=2).export(self)
def loadTagFromJson(string) -> Tag:
tree: Tag = JsonImporter(DictImporter(Tag)).import_(string)
return tree
def getRootTag(file = 'www/tags.json') -> Tag:
with open(file, 'r') as fp:
tree: Tag = JsonImporter(DictImporter(Tag)).read(fp)
# print(tree.descendants)
return tree
# print(RenderTree(tree))

View File

@ -17,7 +17,7 @@
}
#tags li {
line-height: 1.5;
list-style: none;
}
@ -33,20 +33,31 @@
#tags li>div,
#tags li.add-tag {
cursor: pointer
cursor: pointer;
}
#tags .tag-id-root>div {
display: none;
}
#tags li:hover>ul>li.add-tag {
/* #tags li:hover>ul>li.add-tag {
visibility: visible;
;
} */
#tags li.selected > ul>li.add-tag {
display: block;
}
#tags > li > ul >li.add-tag {
display: block;
;
}
#tags .add-tag {
visibility: hidden;
display: none;
;
color: lightgray;
font-size: 80%;
@ -59,9 +70,30 @@
padding: 0;
border: solid 1px black;
border-radius: 2px;
vertical-align: bottom;
vertical-align: middle;
margin-right: 10px;
}
#tags li:hover > div > input.rm-tag{
display: inline-block;
}
#tags li div{
position:relative
}
#tags input.rm-tag:hover{
color: red;
transform: rotate(20deg);
}
#tags input.rm-tag{
/* display: none; */
position:absolute;
right:0;
top:0;
padding:0;
background: none;
border: none;
color: white;
cursor:pointer;
}
#tags .selected>div {
background: lightblue

View File

@ -1,6 +1,7 @@
import json
import logging
import os
import shutil
import tornado.ioloop
import tornado.web
import tornado.websocket
@ -519,7 +520,34 @@ class TagsHandler(tornado.web.RequestHandler):
self.index = index
def get(self):
raise Exception('todo')
self.set_header("Content-Type", "application/json")
with open('www/tags.json', 'r') as fp:
# TODO: enrich with counts
self.write(fp.read())
def put(self):
# data = json.loads(self.request.body)
tree = svganim.strokes.loadTagFromJson(self.request.body)
logger.info(f"New tag tree:\n{tree}")
newTagsContent = tree.toJson()
# save at minute resolution
now = datetime.datetime.utcnow().isoformat(timespec='minutes')
backup_dir = os.path.join(self.config.storage, 'tag_versions')
if not os.path.exists(backup_dir):
logger.warning(f"Creating tags backupdir {backup_dir}")
os.mkdir(backup_dir)
bakfile = os.path.join(backup_dir, f'tags.{now}.json')
logger.info(f"Creating tags backup {bakfile}" )
shutil.copyfile('www/tags.json', bakfile)
with open('www/tags.json', 'w') as fp:
fp.write(newTagsContent)
self.set_status(204)
# print()
class IndexHandler(tornado.web.RequestHandler):
"""Get annotation as svg"""
@ -603,6 +631,9 @@ class Server:
(r"/index", IndexHandler,
{"config": self.config, "index": self.index}),
(r"/tags.json", TagsHandler,
{"config": self.config, "index": self.index}),
(r"/(.*)", StaticFileWithHeaderHandler,
{"path": self.web_root, 'default_filename': 'index.html'}),
],

View File

@ -6,6 +6,8 @@ class AnnotationManager {
this.tagsEl = this.rootEl.querySelector('#tags');
this.annotationsEl = this.rootEl.querySelector('#annotations');
this.selectedAnnotations = [];
this.selectedTag = null;
this.loadTags(tagsUrl);
}
@ -33,6 +35,9 @@ class AnnotationManager {
tagEl.addEventListener('click', (ev) => {
this.selectTag(tag);
})
tagEl.addEventListener('dblclick', (ev) => {
this.renameTag(tag);
})
const colorEl = document.createElement('input');
colorEl.type = 'color';
@ -42,6 +47,16 @@ class AnnotationManager {
});
tagEl.prepend(colorEl)
const rmEl = document.createElement('input');
rmEl.type = 'button';
rmEl.classList.add('rm-tag');
rmEl.value = '🗑';
rmEl.addEventListener('click', (ev) => {
ev.stopPropagation();
this.removeTag(tag);
});
tagEl.appendChild(rmEl)
tagLiEl.classList.add('tag-id-' + tag.id);
tagLiEl.appendChild(tagEl);
@ -70,14 +85,16 @@ class AnnotationManager {
selectTag(tag) {
this.clearSelectedTag();
this.selectedTag = tag;
tag.menuLiEl.classList.add('selected');
this.loadAnnotationsForTag(tag)
}
clearSelectedTag() {
this.selectedTag = null;
const selected = this.tagsEl.querySelectorAll('.selected');
selected.forEach((s) => s.classList.remove('selected'));
this.resetSelectedAnnotations()
//TODO empty annotationEl
this.annotationsEl.innerHTML = "";
}
@ -112,14 +129,33 @@ class AnnotationManager {
buildAnnotationList() {
this.annotationsEl.innerHTML = "";
this.actionsEl = document.createElement('div');
this.actionsEl.classList.add('annotations-actions')
this.buildAnnotationActions();
this.annotationsEl.appendChild(this.actionsEl);
const ulEl = document.createElement('ul');
this.annotations.forEach((annotation, idx) => {
const liEl = document.createElement('li');
const imgEl = document.createElement('img');
const playerEl = document.createElement('div');
const infoEl = document.createElement('div');
const infoEl = document.createElement('span');
infoEl.classList.add('annotation-info');
const selectEl = document.createElement('input');
selectEl.type = 'checkbox';
selectEl.id = 'select-'+annotation.id_hash;
selectEl.addEventListener('change', (ev) => {
if (ev.target.checked) {
this.addAnnotationToSelection(annotation);
} else {
this.removeAnnotationFromSelection(annotation);
}
})
const tag = this.rootTag.find_by_id(annotation.tag);
console.log(tag)
@ -140,6 +176,7 @@ class AnnotationManager {
liEl.appendChild(imgEl);
liEl.appendChild(playerEl);
liEl.appendChild(selectEl);
liEl.appendChild(infoEl);
ulEl.appendChild(liEl);
@ -149,6 +186,76 @@ class AnnotationManager {
}
resetSelectedAnnotations() {
this.selectedAnnotations = [];
this.annotationsEl.querySelectorAll("li input[type='checkbox']").forEach((box) => box.checked = false);
this.buildAnnotationActions()
}
addAnnotationToSelection(annotation) {
if (this.selectedAnnotations.indexOf(annotation) === -1){
this.selectedAnnotations.push(annotation);
}
this.annotationsEl.querySelector('#select-'+annotation.id_hash).checked = true;
this.buildAnnotationActions()
}
removeAnnotationFromSelection(annotation) {
if (this.selectedAnnotations.indexOf(annotation) !== -1){
this.selectedAnnotations.splice(this.selectedAnnotations.indexOf(annotation), 1)
}
this.annotationsEl.querySelector('#select-'+annotation.id_hash).checked = true;
this.buildAnnotationActions()
}
buildAnnotationActions() {
if(!this.actionsEl || !this.annotations.length) return
this.actionsEl.innerHTML = "";
const selectAllLabelEl = document.createElement('label');
selectAllLabelEl.innerText = `Select all`
const selectAllEl = document.createElement('input');
selectAllEl.type = 'checkbox';
selectAllEl.checked = this.annotations.length === this.selectedAnnotations.length;
// selectAllEl.innerText = `Select all ${this.annotations.length} items`;
selectAllEl.addEventListener('change', (ev) => {
if(ev.target.checked) {
this.annotations.forEach((a) => this.addAnnotationToSelection(a));
} else {
this.resetSelectedAnnotations();
}
});
selectAllLabelEl.appendChild(selectAllEl)
this.actionsEl.appendChild(selectAllLabelEl)
if(!this.selectedAnnotations.length) return;
const moveLabelEl = document.createElement('label');
moveLabelEl.innerText = `Change tag for ${this.selectedAnnotations.length} items`
const moveSelectEl = document.createElement('select');
this.rootTag.descendants().forEach((tag, i) => {
const tagEl = document.createElement('option');
tagEl.value = tag.id;
if(tag.id == this.selectedTag.id){
tagEl.selected = true;
}
tagEl.innerHTML = tag.get_indented_name();
moveSelectEl.appendChild(tagEl);
});
moveSelectEl.addEventListener('change', (ev) => {
const tag = this.rootTag.find_by_id(ev.target.value);
console.log(tag);
this.moveSelectedAnnotations(tag);
})
moveLabelEl.appendChild(moveSelectEl)
this.actionsEl.appendChild(moveLabelEl)
}
renameTag(tag) {
// TODO: add button for this
const name = prompt(`Rename tag '${tag.get_name()}':`);
@ -156,15 +263,25 @@ class AnnotationManager {
tag.name = name;
this.saveTags();
this.buildTagList();
}
removeTag(tag) {
if (!confirm(`Do you want to delete ${tag.get_name()}`)) {
return false;
}
// TODO: add button for this
const request = new Request("/tags/" + tag.id);
return fetch(request)
.then(response => response.json())
.then(response => {
if (response.status == 404) {
return [] // not existing tag surely has no annotations
} else {
return response.json()
}
})
.then(annotations => {
if(annotations.length) {
if (annotations.length) {
alert(`Cannot remove '${tag.get_name()}', as it is used for ${annotations.length} annotations.`)
} else {
// TODO: remove tag
@ -172,8 +289,9 @@ class AnnotationManager {
tag.parent = null;
this.saveTags();
this.buildTagList();
}
});
})
}
setTagColor(tag, color) {
@ -183,15 +301,22 @@ class AnnotationManager {
this.saveTags();
}
moveSelectedAnnotations(newTag) {
moveSelectedAnnotations(tag) {
// TODO: add button for this
throw new Error("Not implemented");
alert(`This doesn't work yet! (move to tag ${tag.get_name()})`)
}
saveTags() {
async saveTags() {
const json = this.rootTag.export();
// TODO: save to remote
console.log('save', json)
const response = await fetch('/tags.json', {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: json
}).catch((e) => alert('Something went wrong saving the tags'));
}
}
@ -243,14 +368,19 @@ class Tag {
}
get_name() {
if (this.hasOwnProperty('name')) {
if (this.hasOwnProperty('name') && this.name !== null) {
return this.name;
}
return this.id;
}
get_indented_name() {
const name = this.get_name();
return '&nbsp;'.repeat((this.depth() - 1) * 2) + '- ' + name
}
get_color() {
if (this.hasOwnProperty('color')) {
if (this.hasOwnProperty('color') && this.color !== null) {
return this.color;
}
if (this.parent !== null) {

View File

@ -1,116 +1,172 @@
{
"id": "root",
"children": [
"id": "root",
"name": "root",
"color": null,
"description": "",
"children": [
{
"id": "human-machine",
"name": "Human/machine Entanglements (Appearance/disappearance)",
"color": "#ffa348",
"description": "",
"children": [
{
"id": "human-machine",
"name": "Human/machine Entanglements (Appearance/disappearance)",
"color": "orange",
"children": [
{
"id": "vision",
"name": "Vision",
"color": "orange"
},
{
"id": "sound"
},
{
"id": "behaviour"
},
{
"id": "other-senses",
"name": "Other senses"
}
]
"id": "vision",
"name": "Vision",
"color": null,
"description": ""
},
{
"id": "tensions",
"name": "Tensions, contestations & problems",
"description": "Which problems are identified?, when do they become problems?",
"color": "gray"
"id": "sound",
"name": "sound",
"color": null,
"description": ""
},
{
"id": "security",
"color": "blue",
"name": "Security & types of data",
"children": [
{
"id": "definitions",
"description": "e.g. domain knowledge"
},
{
"id": "input",
"description": "e.g. fake data"
}
]
"id": "behaviour",
"name": "behaviour",
"color": null,
"description": ""
},
{
"id": "actants",
"name": "Actants in relation",
"color": "pink",
"children": [
{
"id": "algorithm"
},
{
"id": "technologies"
},
{
"id": "frt"
},
{
"id": "cameras",
"name": "CCTV & camera's"
},
{
"id": "entities",
"name": "Entities: people, institutions etc."
},
{
"id": "positioning",
"name": "Positioning",
"description": "the positioning of a field/person/oneself in relation to others"
},
{
"id": "inside-outside"
},
{
"id": "public-private"
}
]
},
{
"id": "consequences",
"color": "green",
"children": [
{
"id": "effects"
},
{
"id": "future-imaginaries"
},
{
"id": "speculations",
"description": "what is & what will/can be done."
},
{
"id": "innovations"
}
]
},
{
"id": "hesitation",
"name": "Hesitations & corrections",
"color": "yellow"
},
{
"id": "skip",
"color": "black"
},
{
"id": "todo",
"name": "to do / interesting",
"color": "red"
"id": "other-senses",
"name": "Other senses",
"color": null,
"description": ""
}
]
]
},
{
"id": "tensions",
"name": "Tensions, contestations & problems",
"color": "#77767b",
"description": "Which problems are identified?, when do they become problems?"
},
{
"id": "security",
"name": "Security & types of data",
"color": "#3584e4",
"description": "",
"children": [
{
"id": "definitions",
"name": "definitions",
"color": null,
"description": "e.g. domain knowledge"
},
{
"id": "input",
"name": "input",
"color": null,
"description": "e.g. fake data"
}
]
},
{
"id": "actants",
"name": "Actants in relation",
"color": "#fa08ff",
"description": "",
"children": [
{
"id": "algorithm",
"name": "algorithm",
"color": null,
"description": ""
},
{
"id": "technologies",
"name": "technologies",
"color": null,
"description": ""
},
{
"id": "frt",
"name": "frt",
"color": null,
"description": ""
},
{
"id": "cameras",
"name": "CCTV & camera's",
"color": null,
"description": ""
},
{
"id": "entities",
"name": "Entities: people, institutions etc.",
"color": null,
"description": ""
},
{
"id": "positioning",
"name": "Positioning",
"color": null,
"description": "the positioning of a field/person/oneself in relation to others"
},
{
"id": "inside-outside",
"name": "inside-outside",
"color": null,
"description": ""
},
{
"id": "public-private",
"name": "public-private",
"color": null,
"description": ""
}
]
},
{
"id": "consequences",
"name": "consequences",
"color": "#0add32",
"description": "",
"children": [
{
"id": "effects",
"name": "effects",
"color": null,
"description": ""
},
{
"id": "future-imaginaries",
"name": "future-imaginaries",
"color": null,
"description": ""
},
{
"id": "speculations",
"name": "speculations",
"color": null,
"description": "what is & what will/can be done."
},
{
"id": "innovations",
"name": "innovations",
"color": null,
"description": ""
}
]
},
{
"id": "hesitation",
"name": "Hesitations & corrections",
"color": "#f8e45c",
"description": ""
},
{
"id": "skip",
"name": "skip",
"color": null,
"description": ""
},
{
"id": "todo",
"name": "to do / interesting",
"color": "#ff0000",
"description": ""
}
]
}