You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

501 lines
13 KiB

3 years ago
  1. <template>
  2. <div id="connections-wrapper">
  3. <div class="highlight-container highlight-container--bottom-labels" @click="open" @touchend="open">
  4. <entity-item
  5. v-for="(chunk, i) in chunks"
  6. :key="i"
  7. :spanid="chunk.id"
  8. :content="chunk.text"
  9. :newline="chunk.newline"
  10. :label="chunk.label"
  11. :color="chunk.color"
  12. :labels="labels"
  13. :link-types="linkTypes"
  14. :source-chunk="sourceChunk"
  15. :source-link-type="sourceLinkType"
  16. @remove="deleteAnnotation(chunk.id)"
  17. @update="updateEntity($event.id, chunk.id)"
  18. @selectSource="selectSource(chunk)"
  19. @selectTarget="selectTarget(chunk)"
  20. @deleteLink="deleteLink($event.id, $event.ndx)"
  21. @selectNewLinkType="selectNewLinkType($event)"
  22. @hideAllLinkMenus="hideAllLinkMenus()"
  23. />
  24. <v-menu
  25. v-model="showMenu"
  26. :position-x="x"
  27. :position-y="y"
  28. absolute
  29. offset-y
  30. >
  31. <v-list
  32. dense
  33. min-width="150"
  34. max-height="400"
  35. class="overflow-y-auto"
  36. >
  37. <v-list-item
  38. v-for="(label, i) in labels"
  39. :key="i"
  40. v-shortkey="[label.suffixKey]"
  41. @shortkey="assignLabel(label.id)"
  42. @click="assignLabel(label.id)"
  43. >
  44. <v-list-item-content>
  45. <v-list-item-title v-text="label.text"/>
  46. </v-list-item-content>
  47. <v-list-item-action>
  48. <v-list-item-action-text v-text="label.suffixKey"/>
  49. </v-list-item-action>
  50. </v-list-item>
  51. </v-list>
  52. </v-menu>
  53. </div>
  54. <canvas id="connections">
  55. </canvas>
  56. </div>
  57. </template>
  58. <script>
  59. import EntityItem from './EntityItem'
  60. export default {
  61. components: {
  62. EntityItem
  63. },
  64. props: {
  65. text: {
  66. type: String,
  67. default: '',
  68. required: true
  69. },
  70. labels: {
  71. type: Array,
  72. default: () => ([]),
  73. required: true
  74. },
  75. linkTypes: {
  76. type: Array,
  77. default: () => ([]),
  78. required: true
  79. },
  80. entities: {
  81. type: Array,
  82. default: () => ([]),
  83. required: true
  84. },
  85. deleteAnnotation: {
  86. type: Function,
  87. default: () => ([]),
  88. required: true
  89. },
  90. updateEntity: {
  91. type: Function,
  92. default: () => ([]),
  93. required: true
  94. },
  95. addEntity: {
  96. type: Function,
  97. default: () => ([]),
  98. required: true
  99. },
  100. sourceChunk: {
  101. type: Object,
  102. default: () => {
  103. },
  104. required: true
  105. },
  106. sourceLinkType: {
  107. type: Object,
  108. default: () => {
  109. },
  110. required: true
  111. },
  112. selectSource: {
  113. type: Function,
  114. default: () => ([]),
  115. required: true
  116. },
  117. selectTarget: {
  118. type: Function,
  119. default: () => ([]),
  120. required: true
  121. },
  122. deleteLink: {
  123. type: Function,
  124. default: () => ([]),
  125. required: true
  126. },
  127. selectNewLinkType: {
  128. type: Function,
  129. default: () => ([]),
  130. required: true
  131. },
  132. hideAllLinkMenus: {
  133. type: Function,
  134. default: () => ([]),
  135. required: true
  136. }
  137. },
  138. data() {
  139. return {
  140. showMenu: false,
  141. x: 0,
  142. y: 0,
  143. start: 0,
  144. end: 0
  145. }
  146. },
  147. computed: {
  148. sortedEntities() {
  149. return this.entities.slice().sort((a, b) => a.startOffset - b.startOffset)
  150. },
  151. chunks() {
  152. let chunks = []
  153. let startOffset = 0
  154. // to count the number of characters correctly.
  155. const characters = [...this.text]
  156. for (const entity of this.sortedEntities) {
  157. // add non-entities to chunks.
  158. let piece = characters.slice(startOffset, entity.startOffset).join('')
  159. chunks = chunks.concat(this.makeChunks(piece))
  160. startOffset = entity.endOffset
  161. // add entities to chunks.
  162. const label = this.labelObject[entity.label]
  163. piece = characters.slice(entity.startOffset, entity.endOffset).join('')
  164. chunks.push({
  165. id: entity.id,
  166. label: label.text,
  167. color: label.backgroundColor,
  168. text: piece,
  169. selectedAsLinkSource: false,
  170. links: entity.links ? entity.links.map(link => {
  171. return {
  172. id: link.id,
  173. type: link.type,
  174. color: this.getColor(link.type),
  175. targetId: link.annotation_id_2,
  176. targetLabel: null // target label can be computed only after all chunks are made, see line 204
  177. }
  178. }) : null
  179. })
  180. }
  181. // add the rest of text.
  182. chunks = chunks.concat(this.makeChunks(characters.slice(startOffset, characters.length).join('')));
  183. // populate the links. Must be done after chunk creation
  184. chunks.forEach(chunk => {
  185. if (chunk.links) {
  186. chunk.links.forEach(link => {
  187. link.targetLabel = chunks.find(target => target.id === link.targetId).text;
  188. });
  189. }
  190. });
  191. return chunks;
  192. },
  193. labelObject() {
  194. const obj = {}
  195. for (const label of this.labels) {
  196. obj[label.id] = label
  197. }
  198. return obj
  199. }
  200. },
  201. updated() {
  202. this.$nextTick(() => {
  203. const parentPos = document.getElementById('connections-wrapper').getBoundingClientRect();
  204. const canvas = document.getElementById('connections');
  205. canvas.width = parentPos.width;
  206. canvas.height = parentPos.height;
  207. const ctx = canvas.getContext('2d');
  208. ctx.clearRect(0, 0, parentPos.width, parentPos.height);
  209. const topPoints = this.drawnCountPoints(this.chunks.length);
  210. const bottomPoints = this.drawnCountPoints(this.chunks.length);
  211. const chunks = this.chunks;
  212. chunks.forEach(function(sourceChunk, sourceNdx) {
  213. if (sourceChunk.links) {
  214. sourceChunk.links.forEach(function(link) {
  215. let childPos = document.getElementById('spn-' + sourceChunk.id).getBoundingClientRect();
  216. const y1 = childPos.y - parentPos.y;
  217. childPos = document.getElementById('spn-' + link.targetId).getBoundingClientRect();
  218. const y2 = childPos.y - parentPos.y;
  219. const targetNdx = chunks.findIndex(ch => ch.id === link.targetId);
  220. if (y1 < y2) {
  221. bottomPoints[sourceNdx].count++;
  222. topPoints[targetNdx].count++;
  223. } else if (y1 > y2) {
  224. topPoints[sourceNdx].count++;
  225. bottomPoints[targetNdx].count++;
  226. } else {
  227. bottomPoints[sourceNdx].count++;
  228. bottomPoints[targetNdx].count++;
  229. }
  230. });
  231. }
  232. });
  233. chunks.forEach(function(sourceChunk, sourceNdx) {
  234. if (sourceChunk.links) {
  235. sourceChunk.links.forEach(function(link) {
  236. const sourcePos = document.getElementById('spn-' + sourceChunk.id).getBoundingClientRect();
  237. let x1 = sourcePos.x - parentPos.x;
  238. let y1 = sourcePos.y - parentPos.y;
  239. const targetPos = document.getElementById('spn-' + link.targetId).getBoundingClientRect();
  240. let x2 = targetPos.x - parentPos.x;
  241. let y2 = targetPos.y - parentPos.y;
  242. const targetNdx = chunks.findIndex(ch => ch.id === link.targetId);
  243. ctx.beginPath();
  244. ctx.lineWidth = 3;
  245. ctx.strokeStyle = link.color;
  246. if (y1 < y2) {
  247. bottomPoints[sourceNdx].drawn++;
  248. topPoints[targetNdx].drawn++;
  249. x1 += bottomPoints[sourceNdx].drawn * sourcePos.width / (bottomPoints[sourceNdx].count + 1);
  250. y1 += sourcePos.height;
  251. x2 += topPoints[targetNdx].drawn * targetPos.width / (topPoints[targetNdx].count + 1);
  252. ctx.moveTo(x1, y1);
  253. ctx.lineTo(x1, y1 + 12);
  254. ctx.lineTo(x2, y2 - 12);
  255. ctx.lineTo(x2, y2);
  256. ctx.stroke();
  257. ctx.fillStyle = link.color;
  258. ctx.beginPath();
  259. ctx.moveTo(x2, y2);
  260. ctx.lineTo(x2 - 3, y2 - 5);
  261. ctx.lineTo(x2 + 3, y2 - 5);
  262. ctx.lineTo(x2, y2);
  263. ctx.closePath();
  264. ctx.stroke();
  265. } else if (y1 > y2) {
  266. topPoints[sourceNdx].drawn++;
  267. bottomPoints[targetNdx].drawn++;
  268. x1 += topPoints[sourceNdx].drawn * sourcePos.width / (topPoints[sourceNdx].count + 1);
  269. x2 += bottomPoints[targetNdx].drawn * targetPos.width / (bottomPoints[targetNdx].count + 1);
  270. y2 += targetPos.height;
  271. ctx.moveTo(x1, y1);
  272. ctx.lineTo(x1, y1 - 12);
  273. ctx.lineTo(x2, y2 + 12);
  274. ctx.lineTo(x2, y2);
  275. ctx.stroke();
  276. ctx.fillStyle = link.color;
  277. ctx.beginPath();
  278. ctx.moveTo(x2, y2);
  279. ctx.lineTo(x2 - 3, y2 + 5);
  280. ctx.lineTo(x2 + 3, y2 + 5);
  281. ctx.lineTo(x2, y2);
  282. ctx.closePath();
  283. ctx.stroke();
  284. } else {
  285. bottomPoints[sourceNdx].drawn++;
  286. bottomPoints[targetNdx].drawn++;
  287. x1 += bottomPoints[sourceNdx].drawn * sourcePos.width / (bottomPoints[sourceNdx].count + 1);
  288. y1 += sourcePos.height;
  289. x2 += bottomPoints[targetNdx].drawn * targetPos.width / (bottomPoints[targetNdx].count + 1);
  290. y2 += targetPos.height;
  291. ctx.moveTo(x1, y1);
  292. ctx.lineTo(x1, y1 + 12);
  293. ctx.lineTo(x2, y2 + 12);
  294. ctx.lineTo(x2, y2);
  295. ctx.stroke();
  296. ctx.fillStyle = link.color;
  297. ctx.beginPath();
  298. ctx.moveTo(x2, y2);
  299. ctx.lineTo(x2 - 3, y2 + 5);
  300. ctx.lineTo(x2 + 3, y2 + 5);
  301. ctx.lineTo(x2, y2);
  302. ctx.closePath();
  303. ctx.stroke();
  304. }
  305. });
  306. }
  307. });
  308. });
  309. },
  310. methods: {
  311. makeChunks(text) {
  312. const chunks = []
  313. const snippets = text.split('\n')
  314. for (const snippet of snippets.slice(0, -1)) {
  315. chunks.push({
  316. label: null,
  317. color: null,
  318. text: snippet + '\n',
  319. newline: false
  320. })
  321. chunks.push({
  322. label: null,
  323. color: null,
  324. text: '',
  325. newline: true
  326. })
  327. }
  328. chunks.push({
  329. label: null,
  330. color: null,
  331. text: snippets.slice(-1)[0],
  332. newline: false
  333. })
  334. return chunks
  335. },
  336. show(e) {
  337. e.preventDefault()
  338. this.showMenu = false
  339. this.x = e.clientX || e.changedTouches[0].clientX
  340. this.y = e.clientY || e.changedTouches[0].clientY
  341. this.$nextTick(() => {
  342. this.showMenu = true
  343. })
  344. },
  345. setSpanInfo() {
  346. let selection
  347. // Modern browsers.
  348. if (window.getSelection) {
  349. selection = window.getSelection()
  350. } else if (document.selection) {
  351. selection = document.selection
  352. }
  353. // If nothing is selected.
  354. if (selection.rangeCount <= 0) {
  355. return
  356. }
  357. const range = selection.getRangeAt(0)
  358. const preSelectionRange = range.cloneRange()
  359. preSelectionRange.selectNodeContents(this.$el)
  360. preSelectionRange.setEnd(range.startContainer, range.startOffset)
  361. this.start = [...preSelectionRange.toString()].length
  362. this.end = this.start + [...range.toString()].length
  363. },
  364. validateSpan() {
  365. if ((typeof this.start === 'undefined') || (typeof this.end === 'undefined')) {
  366. return false
  367. }
  368. if (this.start === this.end) {
  369. return false
  370. }
  371. for (const entity of this.entities) {
  372. if ((entity.startOffset <= this.start) && (this.start < entity.endOffset)) {
  373. return false
  374. }
  375. if ((entity.startOffset < this.end) && (this.end <= entity.endOffset)) {
  376. return false
  377. }
  378. if ((this.start < entity.startOffset) && (entity.endOffset < this.end)) {
  379. return false
  380. }
  381. }
  382. return true
  383. },
  384. open(e) {
  385. this.$emit('hideAllLinkMenus');
  386. this.setSpanInfo()
  387. if (this.validateSpan()) {
  388. this.show(e)
  389. }
  390. },
  391. assignLabel(labelId) {
  392. if (this.validateSpan()) {
  393. this.addEntity(this.start, this.end, labelId)
  394. this.showMenu = false
  395. this.start = 0
  396. this.end = 0
  397. }
  398. },
  399. getColor(typeId) {
  400. const type = this.linkTypes.find(type => type.id === typeId);
  401. if (type) {
  402. return type.color;
  403. }
  404. return "#787878";
  405. },
  406. drawnCountPoints(size) {
  407. const points = Array(size);
  408. for (let i = 0; i < points.length; i++) {
  409. points[i] = {
  410. drawn: 0,
  411. count: 0
  412. }
  413. }
  414. return points;
  415. }
  416. }
  417. }
  418. </script>
  419. <style scoped>
  420. .highlight-container.highlight-container--bottom-labels {
  421. align-items: flex-start;
  422. }
  423. .highlight-container {
  424. line-height: 70px !important;
  425. display: flex;
  426. flex-wrap: wrap;
  427. white-space: pre-wrap;
  428. cursor: default;
  429. position: relative;
  430. z-index: 1;
  431. }
  432. .highlight-container.highlight-container--bottom-labels .highlight.bottom {
  433. margin-top: 6px;
  434. }
  435. #connections-wrapper {
  436. position: relative;
  437. }
  438. #connections-wrapper canvas {
  439. position: absolute;
  440. top: 0;
  441. left: 0;
  442. width: 100%;
  443. height: 100%;
  444. z-index: 0;
  445. }
  446. </style>