|
|
<!DOCTYPE html> |
|
|
<html lang="en"> |
|
|
<head> |
|
|
<meta charset="UTF-8"> |
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
|
<title>Text Annotation Tool</title> |
|
|
<style> |
|
|
* { |
|
|
margin: 0; |
|
|
padding: 0; |
|
|
box-sizing: border-box; |
|
|
} |
|
|
|
|
|
body { |
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; |
|
|
background: #f8fafc; |
|
|
min-height: 100vh; |
|
|
overflow-x: hidden; |
|
|
} |
|
|
|
|
|
.container { |
|
|
width: 100vw; |
|
|
min-height: 100vh; |
|
|
background: white; |
|
|
display: flex; |
|
|
flex-direction: column; |
|
|
} |
|
|
|
|
|
.header { |
|
|
background: linear-gradient(90deg, #4f46e5, #7c3aed); |
|
|
color: white; |
|
|
padding: 20px 30px; |
|
|
text-align: center; |
|
|
flex-shrink: 0; |
|
|
} |
|
|
|
|
|
.header h1 { |
|
|
font-size: 2rem; |
|
|
margin-bottom: 8px; |
|
|
font-weight: 700; |
|
|
} |
|
|
|
|
|
.header p { |
|
|
font-size: 1rem; |
|
|
opacity: 0.9; |
|
|
} |
|
|
|
|
|
.main-layout { |
|
|
display: grid; |
|
|
grid-template-columns: 350px 1fr 400px; |
|
|
height: calc(100vh - 120px); |
|
|
flex: 1; |
|
|
} |
|
|
|
|
|
.left-sidebar { |
|
|
background: #f8fafc; |
|
|
border-right: 1px solid #e2e8f0; |
|
|
padding: 20px; |
|
|
overflow-y: auto; |
|
|
display: flex; |
|
|
flex-direction: column; |
|
|
gap: 25px; |
|
|
} |
|
|
|
|
|
.main-content { |
|
|
padding: 20px; |
|
|
overflow-y: auto; |
|
|
display: flex; |
|
|
flex-direction: column; |
|
|
} |
|
|
|
|
|
.right-sidebar { |
|
|
background: #f8fafc; |
|
|
border-left: 1px solid #e2e8f0; |
|
|
padding: 20px; |
|
|
overflow-y: auto; |
|
|
} |
|
|
|
|
|
.input-section { |
|
|
display: flex; |
|
|
flex-direction: column; |
|
|
} |
|
|
|
|
|
.viewer-section { |
|
|
flex: 1; |
|
|
display: flex; |
|
|
flex-direction: column; |
|
|
} |
|
|
|
|
|
.annotation-viewer { |
|
|
flex: 1; |
|
|
border: 2px solid #e5e7eb; |
|
|
border-radius: 8px; |
|
|
padding: 20px; |
|
|
font-family: 'Courier New', monospace; |
|
|
font-size: 14px; |
|
|
line-height: 1.8; |
|
|
background: white; |
|
|
user-select: text; |
|
|
cursor: text; |
|
|
overflow-y: auto; |
|
|
min-height: 300px; |
|
|
} |
|
|
|
|
|
.gliner-section-main { |
|
|
margin-top: 20px; |
|
|
padding: 15px; |
|
|
background: #f0fdf4; |
|
|
border: 1px solid #bbf7d0; |
|
|
border-radius: 8px; |
|
|
} |
|
|
|
|
|
.annotation-card { |
|
|
background: white; |
|
|
border: 1px solid #e2e8f0; |
|
|
border-radius: 8px; |
|
|
padding: 15px; |
|
|
margin-bottom: 12px; |
|
|
cursor: pointer; |
|
|
transition: all 0.2s ease; |
|
|
position: relative; |
|
|
} |
|
|
|
|
|
.annotation-card:hover { |
|
|
border-color: #4f46e5; |
|
|
box-shadow: 0 2px 8px rgba(79, 70, 229, 0.15); |
|
|
transform: translateY(-1px); |
|
|
} |
|
|
|
|
|
.annotation-card.linked { |
|
|
border-left: 4px solid #10b981; |
|
|
} |
|
|
|
|
|
.annotation-text-display { |
|
|
font-weight: 600; |
|
|
color: #374151; |
|
|
margin-bottom: 8px; |
|
|
display: flex; |
|
|
align-items: center; |
|
|
gap: 8px; |
|
|
} |
|
|
|
|
|
.annotation-label-badge { |
|
|
display: inline-block; |
|
|
padding: 2px 8px; |
|
|
border-radius: 12px; |
|
|
font-size: 11px; |
|
|
font-weight: 600; |
|
|
border: 1px solid; |
|
|
} |
|
|
|
|
|
.annotation-meta { |
|
|
color: #6b7280; |
|
|
font-size: 12px; |
|
|
margin-bottom: 10px; |
|
|
} |
|
|
|
|
|
.wikidata-info { |
|
|
background: #f0fdf4; |
|
|
border: 1px solid #bbf7d0; |
|
|
border-radius: 6px; |
|
|
padding: 10px; |
|
|
margin-top: 10px; |
|
|
} |
|
|
|
|
|
.wikidata-link { |
|
|
display: flex; |
|
|
align-items: center; |
|
|
gap: 6px; |
|
|
color: #059669; |
|
|
text-decoration: none; |
|
|
font-size: 12px; |
|
|
font-weight: 500; |
|
|
} |
|
|
|
|
|
.wikidata-link:hover { |
|
|
color: #047857; |
|
|
} |
|
|
|
|
|
.wikidata-description { |
|
|
color: #065f46; |
|
|
font-size: 11px; |
|
|
margin-top: 4px; |
|
|
} |
|
|
|
|
|
.link-controls { |
|
|
display: flex; |
|
|
gap: 8px; |
|
|
margin-top: 10px; |
|
|
} |
|
|
|
|
|
.link-btn { |
|
|
flex: 1; |
|
|
padding: 6px 12px; |
|
|
border: 1px solid #d1d5db; |
|
|
border-radius: 6px; |
|
|
background: white; |
|
|
font-size: 12px; |
|
|
cursor: pointer; |
|
|
transition: all 0.2s ease; |
|
|
} |
|
|
|
|
|
.link-btn:hover { |
|
|
background: #f3f4f6; |
|
|
} |
|
|
|
|
|
.link-btn.primary { |
|
|
background: #4f46e5; |
|
|
color: white; |
|
|
border-color: #4f46e5; |
|
|
} |
|
|
|
|
|
.link-btn.primary:hover { |
|
|
background: #4338ca; |
|
|
} |
|
|
|
|
|
.search-modal { |
|
|
position: fixed; |
|
|
top: 0; |
|
|
left: 0; |
|
|
right: 0; |
|
|
bottom: 0; |
|
|
background: rgba(0, 0, 0, 0.5); |
|
|
display: none; |
|
|
align-items: center; |
|
|
justify-content: center; |
|
|
z-index: 1000; |
|
|
} |
|
|
|
|
|
.search-modal-content { |
|
|
background: white; |
|
|
border-radius: 12px; |
|
|
padding: 24px; |
|
|
width: 90%; |
|
|
max-width: 600px; |
|
|
max-height: 80vh; |
|
|
overflow: hidden; |
|
|
display: flex; |
|
|
flex-direction: column; |
|
|
} |
|
|
|
|
|
.search-modal-header { |
|
|
display: flex; |
|
|
justify-content: between; |
|
|
align-items: center; |
|
|
margin-bottom: 20px; |
|
|
} |
|
|
|
|
|
.search-modal h3 { |
|
|
margin: 0; |
|
|
color: #374151; |
|
|
} |
|
|
|
|
|
.close-modal { |
|
|
background: none; |
|
|
border: none; |
|
|
font-size: 24px; |
|
|
cursor: pointer; |
|
|
color: #6b7280; |
|
|
padding: 0; |
|
|
margin-left: auto; |
|
|
} |
|
|
|
|
|
.search-input-group { |
|
|
display: flex; |
|
|
gap: 10px; |
|
|
margin-bottom: 20px; |
|
|
} |
|
|
|
|
|
.search-input { |
|
|
flex: 1; |
|
|
padding: 10px 12px; |
|
|
border: 1px solid #d1d5db; |
|
|
border-radius: 6px; |
|
|
font-size: 14px; |
|
|
} |
|
|
|
|
|
.search-btn { |
|
|
padding: 10px 20px; |
|
|
background: #4f46e5; |
|
|
color: white; |
|
|
border: none; |
|
|
border-radius: 6px; |
|
|
cursor: pointer; |
|
|
font-weight: 500; |
|
|
} |
|
|
|
|
|
.search-btn:hover { |
|
|
background: #4338ca; |
|
|
} |
|
|
|
|
|
.search-results { |
|
|
flex: 1; |
|
|
overflow-y: auto; |
|
|
border: 1px solid #e2e8f0; |
|
|
border-radius: 6px; |
|
|
max-height: 400px; |
|
|
} |
|
|
|
|
|
.search-result-item { |
|
|
padding: 12px; |
|
|
border-bottom: 1px solid #f3f4f6; |
|
|
cursor: pointer; |
|
|
transition: background 0.2s ease; |
|
|
} |
|
|
|
|
|
.search-result-item:hover { |
|
|
background: #f8fafc; |
|
|
} |
|
|
|
|
|
.search-result-item:last-child { |
|
|
border-bottom: none; |
|
|
} |
|
|
|
|
|
.search-result-label { |
|
|
font-weight: 600; |
|
|
color: #374151; |
|
|
margin-bottom: 4px; |
|
|
} |
|
|
|
|
|
.search-result-qcode { |
|
|
color: #4f46e5; |
|
|
font-size: 12px; |
|
|
font-weight: 500; |
|
|
margin-bottom: 4px; |
|
|
} |
|
|
|
|
|
.search-result-description { |
|
|
color: #6b7280; |
|
|
font-size: 12px; |
|
|
line-height: 1.4; |
|
|
} |
|
|
|
|
|
.qcode-input-section { |
|
|
margin-bottom: 20px; |
|
|
padding: 15px; |
|
|
background: #f8fafc; |
|
|
border-radius: 8px; |
|
|
border: 1px solid #e2e8f0; |
|
|
} |
|
|
|
|
|
.qcode-input-group { |
|
|
display: flex; |
|
|
gap: 10px; |
|
|
align-items: center; |
|
|
} |
|
|
|
|
|
.qcode-input { |
|
|
flex: 1; |
|
|
padding: 8px 12px; |
|
|
border: 1px solid #d1d5db; |
|
|
border-radius: 6px; |
|
|
font-size: 14px; |
|
|
font-family: monospace; |
|
|
} |
|
|
|
|
|
.qcode-btn { |
|
|
padding: 8px 16px; |
|
|
background: #10b981; |
|
|
color: white; |
|
|
border: none; |
|
|
border-radius: 6px; |
|
|
cursor: pointer; |
|
|
font-size: 12px; |
|
|
font-weight: 500; |
|
|
} |
|
|
|
|
|
.qcode-btn:hover { |
|
|
background: #059669; |
|
|
} |
|
|
|
|
|
.auto-link-section { |
|
|
margin-top: 15px; |
|
|
padding: 15px; |
|
|
background: #f0f9ff; |
|
|
border: 1px solid #bae6fd; |
|
|
border-radius: 8px; |
|
|
} |
|
|
|
|
|
.auto-link-checkbox { |
|
|
display: flex; |
|
|
align-items: center; |
|
|
gap: 10px; |
|
|
margin-bottom: 8px; |
|
|
} |
|
|
|
|
|
.auto-link-checkbox input[type="checkbox"] { |
|
|
width: 16px; |
|
|
height: 16px; |
|
|
cursor: pointer; |
|
|
} |
|
|
|
|
|
.auto-link-checkbox label { |
|
|
font-size: 14px; |
|
|
font-weight: 500; |
|
|
color: #374151; |
|
|
cursor: pointer; |
|
|
} |
|
|
|
|
|
.auto-link-info { |
|
|
font-size: 12px; |
|
|
color: #0369a1; |
|
|
line-height: 1.4; |
|
|
} |
|
|
|
|
|
.relationship-labels-section { |
|
|
margin-top: 15px; |
|
|
padding: 15px; |
|
|
background: #fef7ff; |
|
|
border: 1px solid #e879f9; |
|
|
border-radius: 8px; |
|
|
} |
|
|
|
|
|
.tabs { |
|
|
display: flex; |
|
|
border-bottom: 1px solid #e2e8f0; |
|
|
margin-bottom: 20px; |
|
|
} |
|
|
|
|
|
.tab { |
|
|
flex: 1; |
|
|
padding: 12px 16px; |
|
|
background: #f8fafc; |
|
|
border: none; |
|
|
cursor: pointer; |
|
|
font-weight: 500; |
|
|
color: #6b7280; |
|
|
transition: all 0.2s ease; |
|
|
} |
|
|
|
|
|
.tab:first-child { |
|
|
border-radius: 8px 0 0 0; |
|
|
} |
|
|
|
|
|
.tab:last-child { |
|
|
border-radius: 0 8px 0 0; |
|
|
} |
|
|
|
|
|
.tab.active { |
|
|
background: white; |
|
|
color: #4f46e5; |
|
|
border-bottom: 2px solid #4f46e5; |
|
|
} |
|
|
|
|
|
.tab-content { |
|
|
display: none; |
|
|
} |
|
|
|
|
|
.tab-content.active { |
|
|
display: block; |
|
|
} |
|
|
|
|
|
.relationship-card { |
|
|
background: white; |
|
|
border: 1px solid #e2e8f0; |
|
|
border-radius: 8px; |
|
|
padding: 15px; |
|
|
margin-bottom: 12px; |
|
|
transition: all 0.2s ease; |
|
|
} |
|
|
|
|
|
.relationship-card:hover { |
|
|
border-color: #a855f7; |
|
|
box-shadow: 0 2px 8px rgba(168, 85, 247, 0.15); |
|
|
} |
|
|
|
|
|
.relationship-header { |
|
|
display: flex; |
|
|
align-items: center; |
|
|
justify-content: space-between; |
|
|
margin-bottom: 10px; |
|
|
} |
|
|
|
|
|
.relationship-text { |
|
|
font-weight: 600; |
|
|
color: #374151; |
|
|
} |
|
|
|
|
|
.relationship-controls { |
|
|
display: flex; |
|
|
gap: 8px; |
|
|
} |
|
|
|
|
|
.relationship-dropdown { |
|
|
padding: 4px 8px; |
|
|
border: 1px solid #d1d5db; |
|
|
border-radius: 4px; |
|
|
font-size: 12px; |
|
|
background: white; |
|
|
color: #374151; |
|
|
} |
|
|
|
|
|
.reverse-btn, .delete-rel-btn { |
|
|
padding: 4px 8px; |
|
|
border: 1px solid #d1d5db; |
|
|
border-radius: 4px; |
|
|
background: white; |
|
|
font-size: 12px; |
|
|
cursor: pointer; |
|
|
transition: all 0.2s ease; |
|
|
} |
|
|
|
|
|
.reverse-btn:hover { |
|
|
background: #f3f4f6; |
|
|
border-color: #9ca3af; |
|
|
} |
|
|
|
|
|
.delete-rel-btn:hover { |
|
|
background: #fef2f2; |
|
|
border-color: #ef4444; |
|
|
color: #ef4444; |
|
|
} |
|
|
|
|
|
.relationship-info { |
|
|
color: #6b7280; |
|
|
font-size: 12px; |
|
|
margin-top: 8px; |
|
|
} |
|
|
|
|
|
.relationship-editor { |
|
|
background: #f8fafc; |
|
|
border: 1px solid #e2e8f0; |
|
|
border-radius: 6px; |
|
|
padding: 12px; |
|
|
margin-top: 10px; |
|
|
display: none; |
|
|
} |
|
|
|
|
|
.relationship-editor.active { |
|
|
display: block; |
|
|
} |
|
|
|
|
|
.relationship-triplet { |
|
|
display: grid; |
|
|
grid-template-columns: 1fr auto 1fr auto 1fr; |
|
|
gap: 8px; |
|
|
align-items: center; |
|
|
margin-bottom: 10px; |
|
|
} |
|
|
|
|
|
.entity-dropdown, .relation-dropdown { |
|
|
padding: 6px 8px; |
|
|
border: 1px solid #d1d5db; |
|
|
border-radius: 4px; |
|
|
font-size: 12px; |
|
|
background: white; |
|
|
min-width: 120px; |
|
|
max-width: 200px; |
|
|
cursor: pointer; |
|
|
z-index: 1000; |
|
|
position: relative; |
|
|
} |
|
|
|
|
|
.relation-dropdown { |
|
|
min-width: 100px; |
|
|
} |
|
|
|
|
|
.entity-dropdown:focus, .relation-dropdown:focus { |
|
|
outline: 2px solid #3b82f6; |
|
|
border-color: #3b82f6; |
|
|
} |
|
|
|
|
|
.entity-dropdown:hover, .relation-dropdown:hover { |
|
|
border-color: #6b7280; |
|
|
} |
|
|
|
|
|
.relation-arrow { |
|
|
color: #6b7280; |
|
|
font-weight: bold; |
|
|
text-align: center; |
|
|
} |
|
|
|
|
|
.edit-controls { |
|
|
display: flex; |
|
|
gap: 8px; |
|
|
justify-content: flex-end; |
|
|
} |
|
|
|
|
|
.save-btn, .cancel-btn { |
|
|
padding: 4px 12px; |
|
|
border: 1px solid #d1d5db; |
|
|
border-radius: 4px; |
|
|
background: white; |
|
|
font-size: 12px; |
|
|
cursor: pointer; |
|
|
transition: all 0.2s ease; |
|
|
} |
|
|
|
|
|
.save-btn { |
|
|
background: #10b981; |
|
|
color: white; |
|
|
border-color: #10b981; |
|
|
} |
|
|
|
|
|
.save-btn:hover { |
|
|
background: #059669; |
|
|
} |
|
|
|
|
|
.cancel-btn:hover { |
|
|
background: #f3f4f6; |
|
|
} |
|
|
|
|
|
.add-relationship-section { |
|
|
margin-top: 20px; |
|
|
padding: 15px; |
|
|
background: #f0f9ff; |
|
|
border: 1px solid #bae6fd; |
|
|
border-radius: 8px; |
|
|
} |
|
|
|
|
|
.add-relationship-form { |
|
|
display: flex; |
|
|
flex-direction: column; |
|
|
gap: 10px; |
|
|
} |
|
|
|
|
|
.form-row { |
|
|
display: grid; |
|
|
grid-template-columns: 1fr auto 1fr auto 1fr; |
|
|
gap: 8px; |
|
|
align-items: center; |
|
|
} |
|
|
|
|
|
.add-rel-btn { |
|
|
padding: 8px 16px; |
|
|
background: #3b82f6; |
|
|
color: white; |
|
|
border: none; |
|
|
border-radius: 6px; |
|
|
cursor: pointer; |
|
|
font-size: 12px; |
|
|
font-weight: 500; |
|
|
transition: all 0.2s ease; |
|
|
} |
|
|
|
|
|
.add-rel-btn:hover { |
|
|
background: #2563eb; |
|
|
} |
|
|
|
|
|
.add-rel-btn:disabled { |
|
|
opacity: 0.5; |
|
|
cursor: not-allowed; |
|
|
} |
|
|
|
|
|
.section-title { |
|
|
font-size: 1.25rem; |
|
|
font-weight: 600; |
|
|
margin-bottom: 15px; |
|
|
color: #374151; |
|
|
} |
|
|
|
|
|
.text-input { |
|
|
width: 100%; |
|
|
height: 200px; |
|
|
border: 2px solid #e5e7eb; |
|
|
border-radius: 8px; |
|
|
padding: 15px; |
|
|
font-size: 13px; |
|
|
line-height: 1.5; |
|
|
resize: vertical; |
|
|
font-family: 'Courier New', monospace; |
|
|
transition: border-color 0.3s ease; |
|
|
} |
|
|
|
|
|
.text-input:focus { |
|
|
outline: none; |
|
|
border-color: #4f46e5; |
|
|
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1); |
|
|
} |
|
|
|
|
|
.process-btn { |
|
|
margin-top: 15px; |
|
|
background: linear-gradient(90deg, #4f46e5, #7c3aed); |
|
|
color: white; |
|
|
border: none; |
|
|
padding: 12px 24px; |
|
|
border-radius: 8px; |
|
|
font-size: 16px; |
|
|
font-weight: 600; |
|
|
cursor: pointer; |
|
|
transition: all 0.3s ease; |
|
|
} |
|
|
|
|
|
.process-btn:hover { |
|
|
transform: translateY(-2px); |
|
|
box-shadow: 0 5px 15px rgba(79, 70, 229, 0.4); |
|
|
} |
|
|
|
|
|
.process-btn:disabled { |
|
|
opacity: 0.6; |
|
|
cursor: not-allowed; |
|
|
transform: none; |
|
|
} |
|
|
|
|
|
.text-viewer { |
|
|
background: #fafafa; |
|
|
color: #6b7280; |
|
|
font-style: italic; |
|
|
} |
|
|
|
|
|
.text-viewer.selectable { |
|
|
background: white; |
|
|
color: #374151; |
|
|
font-style: normal; |
|
|
} |
|
|
|
|
|
.highlight { |
|
|
border-radius: 3px; |
|
|
padding: 1px 2px; |
|
|
margin: 0 1px; |
|
|
transition: all 0.2s ease; |
|
|
border: 2px solid; |
|
|
position: relative; |
|
|
display: inline-block; |
|
|
} |
|
|
|
|
|
.highlight:hover { |
|
|
opacity: 0.8; |
|
|
transform: scale(1.02); |
|
|
} |
|
|
|
|
|
.highlight:hover .delete-annotation { |
|
|
display: block; |
|
|
} |
|
|
|
|
|
.delete-annotation { |
|
|
position: absolute; |
|
|
top: -8px; |
|
|
right: -8px; |
|
|
width: 16px; |
|
|
height: 16px; |
|
|
background: #ef4444; |
|
|
color: white; |
|
|
border: none; |
|
|
border-radius: 50%; |
|
|
font-size: 10px; |
|
|
cursor: pointer; |
|
|
display: none; |
|
|
z-index: 10; |
|
|
line-height: 1; |
|
|
font-weight: bold; |
|
|
transition: all 0.2s ease; |
|
|
} |
|
|
|
|
|
.delete-annotation:hover { |
|
|
background: #dc2626; |
|
|
transform: scale(1.1); |
|
|
} |
|
|
|
|
|
.labels-section { |
|
|
margin-bottom: 20px; |
|
|
padding: 20px; |
|
|
background: #f8fafc; |
|
|
border-radius: 8px; |
|
|
border: 1px solid #e2e8f0; |
|
|
} |
|
|
|
|
|
.labels-header { |
|
|
display: flex; |
|
|
align-items: center; |
|
|
justify-content: space-between; |
|
|
margin-bottom: 15px; |
|
|
} |
|
|
|
|
|
.labels-grid { |
|
|
display: grid; |
|
|
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); |
|
|
gap: 10px; |
|
|
margin-bottom: 15px; |
|
|
} |
|
|
|
|
|
.label-item { |
|
|
display: flex; |
|
|
align-items: center; |
|
|
justify-content: center; |
|
|
padding: 8px 12px; |
|
|
border-radius: 6px; |
|
|
border: 2px solid; |
|
|
cursor: pointer; |
|
|
font-size: 12px; |
|
|
font-weight: 600; |
|
|
transition: all 0.2s ease; |
|
|
position: relative; |
|
|
} |
|
|
|
|
|
.label-item:hover { |
|
|
transform: translateY(-1px); |
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); |
|
|
} |
|
|
|
|
|
.label-item.selected { |
|
|
transform: scale(1.05); |
|
|
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.3); |
|
|
} |
|
|
|
|
|
.label-item.custom { |
|
|
position: relative; |
|
|
} |
|
|
|
|
|
.delete-label { |
|
|
position: absolute; |
|
|
top: -5px; |
|
|
right: -5px; |
|
|
width: 16px; |
|
|
height: 16px; |
|
|
background: #ef4444; |
|
|
color: white; |
|
|
border: none; |
|
|
border-radius: 50%; |
|
|
font-size: 10px; |
|
|
cursor: pointer; |
|
|
display: none; |
|
|
} |
|
|
|
|
|
.label-item.custom:hover .delete-label { |
|
|
display: block; |
|
|
} |
|
|
|
|
|
.add-label-form { |
|
|
display: flex; |
|
|
gap: 10px; |
|
|
align-items: center; |
|
|
} |
|
|
|
|
|
.label-input { |
|
|
flex: 1; |
|
|
padding: 8px 12px; |
|
|
border: 1px solid #d1d5db; |
|
|
border-radius: 6px; |
|
|
font-size: 14px; |
|
|
} |
|
|
|
|
|
.color-input { |
|
|
width: 40px; |
|
|
height: 32px; |
|
|
border: 1px solid #d1d5db; |
|
|
border-radius: 6px; |
|
|
cursor: pointer; |
|
|
} |
|
|
|
|
|
.add-label-btn { |
|
|
padding: 8px 16px; |
|
|
background: #4f46e5; |
|
|
color: white; |
|
|
border: none; |
|
|
border-radius: 6px; |
|
|
font-size: 14px; |
|
|
cursor: pointer; |
|
|
font-weight: 500; |
|
|
} |
|
|
|
|
|
.add-label-btn:hover { |
|
|
background: #4338ca; |
|
|
} |
|
|
|
|
|
.gliner-section { |
|
|
margin-top: 15px; |
|
|
padding: 15px; |
|
|
background: #f0fdf4; |
|
|
border: 1px solid #bbf7d0; |
|
|
border-radius: 8px; |
|
|
} |
|
|
|
|
|
.gliner-controls { |
|
|
display: flex; |
|
|
gap: 10px; |
|
|
align-items: center; |
|
|
flex-wrap: wrap; |
|
|
} |
|
|
|
|
|
.gliner-btn { |
|
|
padding: 10px 20px; |
|
|
background: linear-gradient(90deg, #10b981, #059669); |
|
|
color: white; |
|
|
border: none; |
|
|
border-radius: 6px; |
|
|
font-size: 14px; |
|
|
font-weight: 600; |
|
|
cursor: pointer; |
|
|
transition: all 0.3s ease; |
|
|
display: flex; |
|
|
align-items: center; |
|
|
gap: 8px; |
|
|
} |
|
|
|
|
|
.gliner-btn:hover { |
|
|
transform: translateY(-1px); |
|
|
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4); |
|
|
} |
|
|
|
|
|
.gliner-btn:disabled { |
|
|
opacity: 0.6; |
|
|
cursor: not-allowed; |
|
|
transform: none; |
|
|
} |
|
|
|
|
|
.gliner-info { |
|
|
font-size: 13px; |
|
|
color: #065f46; |
|
|
margin-top: 8px; |
|
|
} |
|
|
|
|
|
.gliner-results { |
|
|
margin-top: 10px; |
|
|
font-size: 14px; |
|
|
font-weight: 500; |
|
|
} |
|
|
|
|
|
.selected-label-indicator { |
|
|
margin-top: 10px; |
|
|
padding: 10px; |
|
|
background: white; |
|
|
border-radius: 6px; |
|
|
border: 1px solid #e2e8f0; |
|
|
font-size: 14px; |
|
|
font-weight: 500; |
|
|
} |
|
|
|
|
|
.instructions { |
|
|
margin-top: 15px; |
|
|
padding: 15px; |
|
|
background: #f3f4f6; |
|
|
border-radius: 8px; |
|
|
color: #6b7280; |
|
|
font-size: 14px; |
|
|
} |
|
|
|
|
|
.status { |
|
|
margin-top: 10px; |
|
|
padding: 10px; |
|
|
border-radius: 6px; |
|
|
font-size: 14px; |
|
|
font-weight: 500; |
|
|
} |
|
|
|
|
|
.status.success { |
|
|
background: #d1fae5; |
|
|
color: #065f46; |
|
|
border: 1px solid #a7f3d0; |
|
|
} |
|
|
|
|
|
.status.error { |
|
|
background: #fee2e2; |
|
|
color: #991b1b; |
|
|
border: 1px solid #fca5a5; |
|
|
} |
|
|
|
|
|
.annotations-list { |
|
|
margin-top: 20px; |
|
|
} |
|
|
|
|
|
.annotation-item { |
|
|
background: white; |
|
|
border: 1px solid #e5e7eb; |
|
|
border-radius: 6px; |
|
|
padding: 12px; |
|
|
margin-bottom: 10px; |
|
|
font-size: 14px; |
|
|
} |
|
|
|
|
|
.annotation-text { |
|
|
font-weight: 600; |
|
|
color: #374151; |
|
|
margin-bottom: 5px; |
|
|
} |
|
|
|
|
|
.annotation-range { |
|
|
color: #6b7280; |
|
|
font-size: 12px; |
|
|
} |
|
|
|
|
|
.annotation-label { |
|
|
display: inline-block; |
|
|
padding: 2px 6px; |
|
|
border-radius: 4px; |
|
|
font-size: 10px; |
|
|
font-weight: 600; |
|
|
margin-left: 8px; |
|
|
border: 1px solid; |
|
|
} |
|
|
|
|
|
@media (max-width: 1200px) { |
|
|
.main-layout { |
|
|
grid-template-columns: 300px 1fr 350px; |
|
|
} |
|
|
} |
|
|
|
|
|
@media (max-width: 1000px) { |
|
|
.main-layout { |
|
|
grid-template-columns: 1fr; |
|
|
grid-template-rows: auto 1fr auto; |
|
|
} |
|
|
|
|
|
.left-sidebar, .right-sidebar { |
|
|
max-height: 300px; |
|
|
} |
|
|
|
|
|
.header h1 { |
|
|
font-size: 1.8rem; |
|
|
} |
|
|
} |
|
|
|
|
|
@media (max-width: 768px) { |
|
|
.main-layout { |
|
|
height: auto; |
|
|
min-height: calc(100vh - 120px); |
|
|
} |
|
|
|
|
|
.left-sidebar, .right-sidebar { |
|
|
max-height: 250px; |
|
|
} |
|
|
|
|
|
.header h1 { |
|
|
font-size: 1.6rem; |
|
|
} |
|
|
} |
|
|
</style> |
|
|
</head> |
|
|
<body> |
|
|
<div class="container"> |
|
|
<div class="header"> |
|
|
<h1>Text Annotation Tool</h1> |
|
|
<p>Paste your text and highlight sections to create annotations with automatic token boundary detection</p> |
|
|
</div> |
|
|
|
|
|
<div class="main-layout"> |
|
|
|
|
|
<div class="left-sidebar"> |
|
|
<div class="input-section"> |
|
|
<h2 class="section-title">📝 Input Text</h2> |
|
|
<textarea |
|
|
id="textInput" |
|
|
class="text-input" |
|
|
placeholder="Paste your text here... Example: 'The quick brown fox jumps over the lazy dog. This is a sample text for annotation.'" |
|
|
></textarea> |
|
|
<button id="processBtn" class="process-btn">Process Text</button> |
|
|
<div id="status" class="status" style="display: none;"></div> |
|
|
</div> |
|
|
|
|
|
<div class="labels-section"> |
|
|
<div class="labels-header"> |
|
|
<h3 class="section-title" style="margin: 0;">🏷️ Annotation Labels</h3> |
|
|
</div> |
|
|
<div id="labelsGrid" class="labels-grid"></div> |
|
|
<div class="add-label-form"> |
|
|
<input type="text" id="labelInput" class="label-input" placeholder="Enter new label name..." maxlength="20"> |
|
|
<input type="color" id="colorInput" class="color-input" value="#fbbf24"> |
|
|
<button id="addLabelBtn" class="add-label-btn">Add Label</button> |
|
|
</div> |
|
|
<div id="selectedLabelIndicator" class="selected-label-indicator"> |
|
|
No label selected. Click a label above to select it for annotation. |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="gliner-section"> |
|
|
<div class="gliner-controls"> |
|
|
<button id="glinerBtn" class="gliner-btn" disabled> |
|
|
<span>🤖</span> |
|
|
Run GLiNER |
|
|
</button> |
|
|
<div id="glinerResults" class="gliner-results" style="display: none;"></div> |
|
|
</div> |
|
|
<div class="gliner-info"> |
|
|
Automatically extract entities using GLiNER AI model. |
|
|
Process your text first, then click to find entities matching your labels. |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="auto-link-section"> |
|
|
<div class="auto-link-checkbox"> |
|
|
<input type="checkbox" id="autoLinkCheckbox" /> |
|
|
<label for="autoLinkCheckbox">Auto-link to Wikidata</label> |
|
|
</div> |
|
|
<div class="auto-link-info"> |
|
|
When enabled, automatically searches Wikidata for each entity and links to the first result. |
|
|
Works with both manual annotations and GLiNER extractions. |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="relationship-labels-section"> |
|
|
<div class="labels-header"> |
|
|
<h3 class="section-title" style="margin: 0;">🔗 Relationship Labels</h3> |
|
|
</div> |
|
|
<div id="relationshipLabelsGrid" class="labels-grid"></div> |
|
|
<div class="add-label-form"> |
|
|
<input type="text" id="relationshipLabelInput" class="label-input" placeholder="Enter relationship name..." maxlength="20"> |
|
|
<input type="color" id="relationshipColorInput" class="color-input" value="#e879f9"> |
|
|
<button id="addRelationshipLabelBtn" class="add-label-btn">Add Relationship</button> |
|
|
</div> |
|
|
<div class="entity-labels-info" style="margin-top: 15px; padding: 10px; background: #f8fafc; border-radius: 6px; border: 1px solid #e2e8f0; font-size: 12px;"> |
|
|
<div style="font-weight: 600; color: #374151; margin-bottom: 8px;">📋 Entity Labels Found:</div> |
|
|
<div id="entityLabelsDisplay" style="color: #6b7280;">Run GLiNER entities first to see available entity types</div> |
|
|
<div style="margin-top: 8px; color: #6b7280; font-style: italic;">Relationships will be extracted between these entity types</div> |
|
|
</div> |
|
|
|
|
|
<button id="extractRelationshipsBtn" class="gliner-btn" disabled style="margin-top: 15px; width: 100%;"> |
|
|
<span>🔗</span> |
|
|
Extract Relationships |
|
|
</button> |
|
|
<div id="relationshipResults" class="gliner-results" style="display: none;"></div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="main-content"> |
|
|
<div class="viewer-section"> |
|
|
<h2 class="section-title">🎯 Annotation Viewer</h2> |
|
|
<div id="textViewer" class="annotation-viewer"> |
|
|
Process your text to start annotating... |
|
|
</div> |
|
|
<div class="instructions"> |
|
|
<strong>Instructions:</strong> Select a label from the left sidebar, then highlight text in the viewer above. |
|
|
The selection will automatically snap to token boundaries and be colored according to the selected label. |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="right-sidebar"> |
|
|
<div class="tabs"> |
|
|
<button class="tab active" onclick="switchTab('entities')">🏷️ Entities</button> |
|
|
<button class="tab" onclick="switchTab('relationships')">🔗 Relationships</button> |
|
|
</div> |
|
|
|
|
|
<div id="entitiesTab" class="tab-content active"> |
|
|
<div id="sidebarAnnotations"></div> |
|
|
</div> |
|
|
|
|
|
<div id="relationshipsTab" class="tab-content"> |
|
|
<div class="add-relationship-section"> |
|
|
<h4 style="margin: 0 0 10px 0; color: #374151;">➕ Add New Relationship</h4> |
|
|
<div class="add-relationship-form"> |
|
|
<div class="form-row"> |
|
|
<select id="newSubjectSelect" class="entity-dropdown"> |
|
|
<option value="">Select Subject</option> |
|
|
</select> |
|
|
<div class="relation-arrow">→</div> |
|
|
<select id="newRelationSelect" class="relation-dropdown"> |
|
|
<option value="">Select Relation</option> |
|
|
</select> |
|
|
<div class="relation-arrow">→</div> |
|
|
<select id="newObjectSelect" class="entity-dropdown"> |
|
|
<option value="">Select Object</option> |
|
|
</select> |
|
|
</div> |
|
|
<button id="addRelationshipBtn" class="add-rel-btn" disabled>Add Relationship</button> |
|
|
</div> |
|
|
</div> |
|
|
<div id="sidebarRelationships"></div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div id="searchModal" class="search-modal"> |
|
|
<div class="search-modal-content"> |
|
|
<div class="search-modal-header"> |
|
|
<h3>Link to Wikidata</h3> |
|
|
<button class="close-modal" onclick="closeSearchModal()">×</button> |
|
|
</div> |
|
|
|
|
|
<div class="qcode-input-section"> |
|
|
<label style="display: block; margin-bottom: 8px; font-weight: 500; color: #374151;">Direct Q-code Input:</label> |
|
|
<div class="qcode-input-group"> |
|
|
<input type="text" id="qcodeInput" class="qcode-input" placeholder="Q12345 or 12345"> |
|
|
<button id="qcodeBtn" class="qcode-btn">Link Q-code</button> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div style="text-align: center; margin: 10px 0; color: #6b7280; font-size: 14px;">OR</div> |
|
|
|
|
|
<div class="search-input-group"> |
|
|
<input type="text" id="wikidataSearchInput" class="search-input" placeholder="Search Wikidata entities..."> |
|
|
<button id="wikidataSearchBtn" class="search-btn">Search</button> |
|
|
</div> |
|
|
|
|
|
<div id="searchResults" class="search-results"></div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<script> |
|
|
let currentTokens = []; |
|
|
let annotations = []; |
|
|
let annotationCounter = 0; |
|
|
let availableLabels = []; |
|
|
let selectedLabel = null; |
|
|
let originalText = ''; |
|
|
let currentAnnotationForLink = null; |
|
|
let relationships = []; |
|
|
let relationshipCounter = 0; |
|
|
let availableRelationshipLabels = []; |
|
|
let currentActiveTab = 'entities'; |
|
|
|
|
|
const textInput = document.getElementById('textInput'); |
|
|
const processBtn = document.getElementById('processBtn'); |
|
|
const textViewer = document.getElementById('textViewer'); |
|
|
const status = document.getElementById('status'); |
|
|
const annotationsList = document.getElementById('annotationsList'); |
|
|
const labelsGrid = document.getElementById('labelsGrid'); |
|
|
const labelInput = document.getElementById('labelInput'); |
|
|
const colorInput = document.getElementById('colorInput'); |
|
|
const addLabelBtn = document.getElementById('addLabelBtn'); |
|
|
const selectedLabelIndicator = document.getElementById('selectedLabelIndicator'); |
|
|
const glinerBtn = document.getElementById('glinerBtn'); |
|
|
const glinerResults = document.getElementById('glinerResults'); |
|
|
const sidebarAnnotations = document.getElementById('sidebarAnnotations'); |
|
|
const searchModal = document.getElementById('searchModal'); |
|
|
const wikidataSearchInput = document.getElementById('wikidataSearchInput'); |
|
|
const wikidataSearchBtn = document.getElementById('wikidataSearchBtn'); |
|
|
const searchResults = document.getElementById('searchResults'); |
|
|
const qcodeInput = document.getElementById('qcodeInput'); |
|
|
const qcodeBtn = document.getElementById('qcodeBtn'); |
|
|
const autoLinkCheckbox = document.getElementById('autoLinkCheckbox'); |
|
|
const relationshipLabelsGrid = document.getElementById('relationshipLabelsGrid'); |
|
|
const relationshipLabelInput = document.getElementById('relationshipLabelInput'); |
|
|
const relationshipColorInput = document.getElementById('relationshipColorInput'); |
|
|
const addRelationshipLabelBtn = document.getElementById('addRelationshipLabelBtn'); |
|
|
const extractRelationshipsBtn = document.getElementById('extractRelationshipsBtn'); |
|
|
const relationshipResults = document.getElementById('relationshipResults'); |
|
|
const sidebarRelationships = document.getElementById('sidebarRelationships'); |
|
|
const newSubjectSelect = document.getElementById('newSubjectSelect'); |
|
|
const newRelationSelect = document.getElementById('newRelationSelect'); |
|
|
const newObjectSelect = document.getElementById('newObjectSelect'); |
|
|
const addRelationshipBtn = document.getElementById('addRelationshipBtn'); |
|
|
const entityLabelsDisplay = document.getElementById('entityLabelsDisplay'); |
|
|
|
|
|
function showStatus(message, isError = false) { |
|
|
status.textContent = message; |
|
|
status.className = `status ${isError ? 'error' : 'success'}`; |
|
|
status.style.display = 'block'; |
|
|
|
|
|
setTimeout(() => { |
|
|
status.style.display = 'none'; |
|
|
}, 3000); |
|
|
} |
|
|
|
|
|
function updateAnnotationsList() { |
|
|
updateSidebarAnnotations(); |
|
|
// Update relationship form when annotations change |
|
|
updateRelationshipForm(); |
|
|
// Update entity labels display |
|
|
updateEntityLabelsDisplay(); |
|
|
} |
|
|
|
|
|
function updateSidebarAnnotations() { |
|
|
if (annotations.length === 0) { |
|
|
sidebarAnnotations.innerHTML = '<p style="color: #6b7280; font-style: italic; text-align: center; margin-top: 40px;">No annotations yet.<br><br>Select a label and highlight text in the viewer to create annotations.</p>'; |
|
|
return; |
|
|
} |
|
|
|
|
|
sidebarAnnotations.innerHTML = annotations.map(annotation => { |
|
|
const label = availableLabels.find(l => l.name === annotation.label); |
|
|
const labelStyle = label ? `background-color: ${label.color}; border-color: ${label.border}; color: ${label.border};` : ''; |
|
|
const isLinked = annotation.wikidata && annotation.wikidata.id; |
|
|
|
|
|
return ` |
|
|
<div class="annotation-card ${isLinked ? 'linked' : ''}" data-annotation-id="${annotation.id}"> |
|
|
<div class="annotation-text-display"> |
|
|
"${annotation.text}" |
|
|
<span class="annotation-label-badge" style="${labelStyle}">${annotation.label}</span> |
|
|
</div> |
|
|
<div class="annotation-meta"> |
|
|
Characters ${annotation.start}-${annotation.end} | Token boundary aligned |
|
|
</div> |
|
|
|
|
|
${isLinked ? ` |
|
|
<div class="wikidata-info"> |
|
|
<a href="${annotation.wikidata.url}" target="_blank" class="wikidata-link"> |
|
|
🔗 ${annotation.wikidata.id}: ${annotation.wikidata.label} |
|
|
</a> |
|
|
${annotation.wikidata.description ? `<div class="wikidata-description">${annotation.wikidata.description}</div>` : ''} |
|
|
</div> |
|
|
` : ''} |
|
|
|
|
|
<div class="link-controls"> |
|
|
<button class="link-btn primary" onclick="openSearchModal(${annotation.id})"> |
|
|
${isLinked ? 'Change Link' : 'Link to Wikidata'} |
|
|
</button> |
|
|
${isLinked ? `<button class="link-btn" onclick="unlinkAnnotation(${annotation.id})">Unlink</button>` : ''} |
|
|
</div> |
|
|
</div> |
|
|
`; |
|
|
}).join(''); |
|
|
} |
|
|
|
|
|
function renderLabels() { |
|
|
labelsGrid.innerHTML = availableLabels.map(label => ` |
|
|
<div class="label-item ${selectedLabel?.name === label.name ? 'selected' : ''} ${!label.isDefault ? 'custom' : ''}" |
|
|
data-label="${label.name}" |
|
|
style="background-color: ${label.color}; border-color: ${label.border}; color: ${label.border};"> |
|
|
${label.name} |
|
|
${!label.isDefault ? `<button class="delete-label" onclick="deleteLabel('${label.name}')" title="Delete label">×</button>` : ''} |
|
|
</div> |
|
|
`).join(''); |
|
|
|
|
|
// Update selected label indicator |
|
|
if (selectedLabel) { |
|
|
selectedLabelIndicator.innerHTML = ` |
|
|
<span style="color: ${selectedLabel.border};">●</span> |
|
|
Selected: <strong>${selectedLabel.name}</strong> - Click text to annotate with this label |
|
|
`; |
|
|
selectedLabelIndicator.style.backgroundColor = selectedLabel.color; |
|
|
selectedLabelIndicator.style.borderColor = selectedLabel.border; |
|
|
} else { |
|
|
selectedLabelIndicator.innerHTML = 'No label selected. Click a label above to select it for annotation.'; |
|
|
selectedLabelIndicator.style.backgroundColor = 'white'; |
|
|
selectedLabelIndicator.style.borderColor = '#e2e8f0'; |
|
|
} |
|
|
|
|
|
// Update GLiNER button state |
|
|
updateGlinerButton(); |
|
|
|
|
|
// Update relationship extraction button state |
|
|
updateExtractRelationshipsButton(); |
|
|
} |
|
|
|
|
|
function selectLabel(labelName) { |
|
|
selectedLabel = availableLabels.find(l => l.name === labelName); |
|
|
renderLabels(); |
|
|
} |
|
|
|
|
|
function addLabel() { |
|
|
const name = labelInput.value.trim().toUpperCase(); |
|
|
const color = colorInput.value; |
|
|
|
|
|
if (!name) { |
|
|
showStatus('Please enter a label name.', true); |
|
|
return; |
|
|
} |
|
|
|
|
|
if (availableLabels.some(l => l.name === name)) { |
|
|
showStatus('Label already exists.', true); |
|
|
return; |
|
|
} |
|
|
|
|
|
// Generate border color (darker version of the selected color) |
|
|
const borderColor = adjustBrightness(color, -40); |
|
|
|
|
|
const newLabel = { |
|
|
name: name, |
|
|
color: color + '40', // Add transparency |
|
|
border: borderColor, |
|
|
isDefault: false |
|
|
}; |
|
|
|
|
|
availableLabels.push(newLabel); |
|
|
renderLabels(); |
|
|
|
|
|
labelInput.value = ''; |
|
|
colorInput.value = '#fbbf24'; |
|
|
|
|
|
showStatus(`Label "${name}" added successfully!`); |
|
|
} |
|
|
|
|
|
function deleteLabel(labelName) { |
|
|
if (confirm(`Delete label "${labelName}"?`)) { |
|
|
availableLabels = availableLabels.filter(l => l.name !== labelName); |
|
|
|
|
|
if (selectedLabel?.name === labelName) { |
|
|
selectedLabel = null; |
|
|
} |
|
|
|
|
|
// Remove annotations with this label |
|
|
annotations = annotations.filter(a => a.label !== labelName); |
|
|
|
|
|
renderLabels(); |
|
|
updateAnnotationsList(); |
|
|
showStatus(`Label "${labelName}" deleted.`); |
|
|
} |
|
|
} |
|
|
|
|
|
function adjustBrightness(hex, percent) { |
|
|
// Remove # if present |
|
|
hex = hex.replace('#', ''); |
|
|
|
|
|
// Convert to RGB |
|
|
const r = parseInt(hex.substr(0, 2), 16); |
|
|
const g = parseInt(hex.substr(2, 2), 16); |
|
|
const b = parseInt(hex.substr(4, 2), 16); |
|
|
|
|
|
// Adjust brightness |
|
|
const newR = Math.max(0, Math.min(255, r + (r * percent / 100))); |
|
|
const newG = Math.max(0, Math.min(255, g + (g * percent / 100))); |
|
|
const newB = Math.max(0, Math.min(255, b + (b * percent / 100))); |
|
|
|
|
|
// Convert back to hex |
|
|
return '#' + |
|
|
Math.round(newR).toString(16).padStart(2, '0') + |
|
|
Math.round(newG).toString(16).padStart(2, '0') + |
|
|
Math.round(newB).toString(16).padStart(2, '0'); |
|
|
} |
|
|
|
|
|
async function loadDefaultLabels() { |
|
|
try { |
|
|
const response = await fetch('/get_default_labels'); |
|
|
const data = await response.json(); |
|
|
|
|
|
availableLabels = data.labels.map(label => ({ |
|
|
...label, |
|
|
isDefault: true |
|
|
})); |
|
|
|
|
|
renderLabels(); |
|
|
} catch (error) { |
|
|
console.error('Error loading default labels:', error); |
|
|
showStatus('Error loading labels.', true); |
|
|
} |
|
|
} |
|
|
|
|
|
function updateGlinerButton() { |
|
|
const hasText = originalText && originalText !== 'Process your text to start annotating...'; |
|
|
const hasLabels = availableLabels.length > 0; |
|
|
|
|
|
glinerBtn.disabled = !hasText || !hasLabels; |
|
|
|
|
|
if (!hasText) { |
|
|
glinerBtn.title = 'Process text first'; |
|
|
} else if (!hasLabels) { |
|
|
glinerBtn.title = 'Add some labels first'; |
|
|
} else { |
|
|
glinerBtn.title = 'Run automatic entity extraction'; |
|
|
} |
|
|
} |
|
|
|
|
|
async function runGliner() { |
|
|
const text = originalText; // Use original text, not current textContent |
|
|
const labelNames = availableLabels.map(label => label.name); |
|
|
|
|
|
if (!text || text === 'Process your text to start annotating...') { |
|
|
showStatus('Please process text first.', true); |
|
|
return; |
|
|
} |
|
|
|
|
|
if (labelNames.length === 0) { |
|
|
showStatus('Please add some labels first.', true); |
|
|
return; |
|
|
} |
|
|
|
|
|
glinerBtn.disabled = true; |
|
|
glinerBtn.innerHTML = '<span>⏳</span> Running GLiNER...'; |
|
|
glinerResults.style.display = 'none'; |
|
|
|
|
|
try { |
|
|
const response = await fetch('/run_gliner', { |
|
|
method: 'POST', |
|
|
headers: { |
|
|
'Content-Type': 'application/json', |
|
|
}, |
|
|
body: JSON.stringify({ |
|
|
text: text, |
|
|
labels: labelNames |
|
|
}) |
|
|
}); |
|
|
|
|
|
if (!response.ok) { |
|
|
throw new Error('Failed to run GLiNER'); |
|
|
} |
|
|
|
|
|
const data = await response.json(); |
|
|
|
|
|
// Add all found entities as annotations |
|
|
let addedCount = 0; |
|
|
data.entities.forEach(entity => { |
|
|
// Check if this annotation already exists |
|
|
const exists = annotations.some(ann => |
|
|
ann.start === entity.start && |
|
|
ann.end === entity.end && |
|
|
ann.label === entity.label |
|
|
); |
|
|
|
|
|
if (!exists) { |
|
|
const annotation = { |
|
|
id: ++annotationCounter, |
|
|
text: entity.text, |
|
|
start: entity.start, |
|
|
end: entity.end, |
|
|
label: entity.label, |
|
|
confidence: entity.confidence |
|
|
}; |
|
|
annotations.push(annotation); |
|
|
addedCount++; |
|
|
} |
|
|
}); |
|
|
|
|
|
// Auto-link new annotations if enabled |
|
|
if (autoLinkCheckbox.checked && addedCount > 0) { |
|
|
await autoLinkAllAnnotations(); |
|
|
} |
|
|
|
|
|
// Update UI |
|
|
updateAnnotationsList(); |
|
|
renderHighlights(); |
|
|
|
|
|
// Show results |
|
|
glinerResults.innerHTML = `Found ${data.total_found} entities, added ${addedCount} new annotations`; |
|
|
glinerResults.style.display = 'block'; |
|
|
glinerResults.style.color = addedCount > 0 ? '#065f46' : '#6b7280'; |
|
|
|
|
|
showStatus(`GLiNER completed! Found ${data.total_found} entities, added ${addedCount} new annotations.`); |
|
|
|
|
|
} catch (error) { |
|
|
showStatus('Error running GLiNER. Please try again.', true); |
|
|
console.error('Error:', error); |
|
|
glinerResults.innerHTML = 'Error occurred during processing'; |
|
|
glinerResults.style.display = 'block'; |
|
|
glinerResults.style.color = '#dc2626'; |
|
|
} finally { |
|
|
glinerBtn.disabled = false; |
|
|
glinerBtn.innerHTML = '<span>🤖</span> Run GLiNER'; |
|
|
updateGlinerButton(); |
|
|
} |
|
|
} |
|
|
|
|
|
async function processText() { |
|
|
const text = textInput.value.trim(); |
|
|
|
|
|
if (!text) { |
|
|
showStatus('Please enter some text first.', true); |
|
|
return; |
|
|
} |
|
|
|
|
|
processBtn.disabled = true; |
|
|
processBtn.textContent = 'Processing...'; |
|
|
|
|
|
try { |
|
|
const response = await fetch('/tokenize', { |
|
|
method: 'POST', |
|
|
headers: { |
|
|
'Content-Type': 'application/json', |
|
|
}, |
|
|
body: JSON.stringify({ text: text }) |
|
|
}); |
|
|
|
|
|
if (!response.ok) { |
|
|
throw new Error('Failed to process text'); |
|
|
} |
|
|
|
|
|
const data = await response.json(); |
|
|
currentTokens = data.tokens; |
|
|
|
|
|
// Store the original text and display it in the viewer |
|
|
originalText = text; |
|
|
textViewer.textContent = text; |
|
|
textViewer.classList.add('selectable'); |
|
|
|
|
|
// Clear previous annotations |
|
|
annotations = []; |
|
|
updateAnnotationsList(); |
|
|
|
|
|
// Enable GLiNER button if there are labels |
|
|
updateGlinerButton(); |
|
|
|
|
|
// Enable relationship extraction button if there are relationship labels |
|
|
updateExtractRelationshipsButton(); |
|
|
|
|
|
showStatus(`Text processed successfully! Found ${currentTokens.length} tokens.`); |
|
|
|
|
|
} catch (error) { |
|
|
showStatus('Error processing text. Please try again.', true); |
|
|
console.error('Error:', error); |
|
|
} finally { |
|
|
processBtn.disabled = false; |
|
|
processBtn.textContent = 'Process Text'; |
|
|
} |
|
|
} |
|
|
|
|
|
async function handleTextSelection() { |
|
|
const selection = window.getSelection(); |
|
|
|
|
|
if (selection.rangeCount === 0 || selection.isCollapsed) { |
|
|
return; |
|
|
} |
|
|
|
|
|
if (!selectedLabel) { |
|
|
showStatus('Please select a label first before annotating.', true); |
|
|
selection.removeAllRanges(); |
|
|
return; |
|
|
} |
|
|
|
|
|
// Use the new method to get accurate offsets |
|
|
const selectionData = getSelectionOffsets(); |
|
|
|
|
|
if (!selectionData || selectionData.start === -1 || selectionData.end === -1) { |
|
|
showStatus('Could not determine selection boundaries.', true); |
|
|
selection.removeAllRanges(); |
|
|
return; |
|
|
} |
|
|
|
|
|
const startOffset = selectionData.start; |
|
|
const endOffset = selectionData.end; |
|
|
|
|
|
if (startOffset === endOffset) { |
|
|
return; |
|
|
} |
|
|
|
|
|
try { |
|
|
const response = await fetch('/find_token_boundaries', { |
|
|
method: 'POST', |
|
|
headers: { |
|
|
'Content-Type': 'application/json', |
|
|
}, |
|
|
body: JSON.stringify({ |
|
|
text: originalText, // Use original text, not current textContent |
|
|
start: Math.min(startOffset, endOffset), |
|
|
end: Math.max(startOffset, endOffset), |
|
|
label: selectedLabel.name |
|
|
}) |
|
|
}); |
|
|
|
|
|
if (!response.ok) { |
|
|
throw new Error('Failed to find token boundaries'); |
|
|
} |
|
|
|
|
|
const data = await response.json(); |
|
|
|
|
|
// Check if there's already an annotation at this exact position |
|
|
const existingAnnotation = annotations.find(ann => |
|
|
ann.start === data.start && ann.end === data.end |
|
|
); |
|
|
|
|
|
if (existingAnnotation) { |
|
|
// If annotation exists with same label, do nothing |
|
|
if (existingAnnotation.label === data.label) { |
|
|
showStatus(`Text already annotated as ${data.label}`, true); |
|
|
selection.removeAllRanges(); |
|
|
return; |
|
|
} |
|
|
|
|
|
// If annotation exists with different label, update it |
|
|
existingAnnotation.label = data.label; |
|
|
showStatus(`Updated annotation to ${data.label}`); |
|
|
} else { |
|
|
// Create new annotation |
|
|
const annotation = { |
|
|
id: ++annotationCounter, |
|
|
text: data.selected_text, |
|
|
start: data.start, |
|
|
end: data.end, |
|
|
label: data.label |
|
|
}; |
|
|
annotations.push(annotation); |
|
|
|
|
|
// Auto-link if enabled |
|
|
if (autoLinkCheckbox.checked) { |
|
|
await autoLinkAnnotation(annotation); |
|
|
} |
|
|
|
|
|
showStatus(`Annotated "${data.selected_text}" as ${data.label}`); |
|
|
} |
|
|
|
|
|
updateAnnotationsList(); |
|
|
|
|
|
// Re-render all highlights |
|
|
renderHighlights(); |
|
|
|
|
|
// Clear selection |
|
|
selection.removeAllRanges(); |
|
|
|
|
|
} catch (error) { |
|
|
showStatus('Error creating annotation. Please try again.', true); |
|
|
console.error('Error:', error); |
|
|
} |
|
|
} |
|
|
|
|
|
function getTextOffset(node, offset) { |
|
|
// Build a map of the actual text content and its positions |
|
|
let textOffset = 0; |
|
|
let found = false; |
|
|
|
|
|
// Walk through all nodes in the text viewer |
|
|
const walker = document.createTreeWalker( |
|
|
textViewer, |
|
|
NodeFilter.SHOW_TEXT, |
|
|
{ |
|
|
acceptNode: function(textNode) { |
|
|
// Skip text nodes that are inside delete buttons |
|
|
if (textNode.parentNode && textNode.parentNode.classList && |
|
|
textNode.parentNode.classList.contains('delete-annotation')) { |
|
|
return NodeFilter.FILTER_REJECT; |
|
|
} |
|
|
return NodeFilter.FILTER_ACCEPT; |
|
|
} |
|
|
}, |
|
|
false |
|
|
); |
|
|
|
|
|
let currentNode; |
|
|
while (currentNode = walker.nextNode()) { |
|
|
if (currentNode === node) { |
|
|
return textOffset + offset; |
|
|
} |
|
|
textOffset += currentNode.textContent.length; |
|
|
} |
|
|
|
|
|
// If we didn't find the node, try a fallback method |
|
|
return offset; |
|
|
} |
|
|
|
|
|
function getSelectionOffsets() { |
|
|
const selection = window.getSelection(); |
|
|
if (selection.rangeCount === 0) { |
|
|
return null; |
|
|
} |
|
|
|
|
|
const range = selection.getRangeAt(0); |
|
|
|
|
|
// Get the actual text content without HTML elements (except delete buttons) |
|
|
let fullText = ''; |
|
|
let startOffset = -1; |
|
|
let endOffset = -1; |
|
|
let currentOffset = 0; |
|
|
|
|
|
const walker = document.createTreeWalker( |
|
|
textViewer, |
|
|
NodeFilter.SHOW_TEXT, |
|
|
{ |
|
|
acceptNode: function(node) { |
|
|
// Skip text nodes that are inside delete buttons |
|
|
if (node.parentNode && node.parentNode.classList && |
|
|
node.parentNode.classList.contains('delete-annotation')) { |
|
|
return NodeFilter.FILTER_REJECT; |
|
|
} |
|
|
return NodeFilter.FILTER_ACCEPT; |
|
|
} |
|
|
}, |
|
|
false |
|
|
); |
|
|
|
|
|
let textNode; |
|
|
while (textNode = walker.nextNode()) { |
|
|
const nodeText = textNode.textContent; |
|
|
const nodeLength = nodeText.length; |
|
|
|
|
|
// Check if this node contains the start of our selection |
|
|
if (textNode === range.startContainer) { |
|
|
startOffset = currentOffset + range.startOffset; |
|
|
} |
|
|
|
|
|
// Check if this node contains the end of our selection |
|
|
if (textNode === range.endContainer) { |
|
|
endOffset = currentOffset + range.endOffset; |
|
|
} |
|
|
|
|
|
fullText += nodeText; |
|
|
currentOffset += nodeLength; |
|
|
} |
|
|
|
|
|
return { |
|
|
start: startOffset, |
|
|
end: endOffset, |
|
|
text: fullText |
|
|
}; |
|
|
} |
|
|
|
|
|
function getPlainTextLength() { |
|
|
// Calculate the length of the original text without any HTML elements |
|
|
return originalText.length; |
|
|
} |
|
|
|
|
|
function renderHighlights() { |
|
|
// Always use the original text, never the current textContent |
|
|
const text = originalText; |
|
|
|
|
|
if (!text) { |
|
|
return; |
|
|
} |
|
|
|
|
|
// Sort annotations by start position |
|
|
const sortedAnnotations = [...annotations].sort((a, b) => a.start - b.start); |
|
|
|
|
|
textViewer.innerHTML = ''; |
|
|
let lastEnd = 0; |
|
|
|
|
|
sortedAnnotations.forEach(annotation => { |
|
|
// Add text before annotation |
|
|
if (annotation.start > lastEnd) { |
|
|
textViewer.appendChild(document.createTextNode(text.substring(lastEnd, annotation.start))); |
|
|
} |
|
|
|
|
|
// Add highlighted text with delete button |
|
|
const label = availableLabels.find(l => l.name === annotation.label); |
|
|
const highlight = document.createElement('span'); |
|
|
highlight.className = 'highlight'; |
|
|
highlight.textContent = text.substring(annotation.start, annotation.end); |
|
|
highlight.dataset.annotationId = annotation.id; |
|
|
|
|
|
if (label) { |
|
|
highlight.style.backgroundColor = label.color; |
|
|
highlight.style.borderColor = label.border; |
|
|
} |
|
|
|
|
|
highlight.title = `${annotation.label}: ${annotation.text}`; |
|
|
|
|
|
// Add delete button |
|
|
const deleteBtn = document.createElement('button'); |
|
|
deleteBtn.className = 'delete-annotation'; |
|
|
deleteBtn.innerHTML = '×'; |
|
|
deleteBtn.title = 'Delete annotation'; |
|
|
deleteBtn.onclick = (e) => { |
|
|
e.stopPropagation(); |
|
|
deleteAnnotation(annotation.id); |
|
|
}; |
|
|
|
|
|
highlight.appendChild(deleteBtn); |
|
|
textViewer.appendChild(highlight); |
|
|
|
|
|
lastEnd = Math.max(lastEnd, annotation.end); |
|
|
}); |
|
|
|
|
|
// Add remaining text |
|
|
if (lastEnd < text.length) { |
|
|
textViewer.appendChild(document.createTextNode(text.substring(lastEnd))); |
|
|
} |
|
|
} |
|
|
|
|
|
function deleteAnnotation(annotationId) { |
|
|
// Find and remove the annotation |
|
|
const annotationIndex = annotations.findIndex(ann => ann.id === annotationId); |
|
|
|
|
|
if (annotationIndex !== -1) { |
|
|
const deletedAnnotation = annotations[annotationIndex]; |
|
|
annotations.splice(annotationIndex, 1); |
|
|
|
|
|
// Update UI |
|
|
updateAnnotationsList(); |
|
|
renderHighlights(); |
|
|
|
|
|
showStatus(`Deleted annotation: "${deletedAnnotation.text}" (${deletedAnnotation.label})`); |
|
|
} |
|
|
} |
|
|
|
|
|
// Wikidata functions |
|
|
function openSearchModal(annotationId) { |
|
|
currentAnnotationForLink = annotations.find(ann => ann.id === annotationId); |
|
|
if (!currentAnnotationForLink) return; |
|
|
|
|
|
// Pre-fill search with annotation text |
|
|
wikidataSearchInput.value = currentAnnotationForLink.text; |
|
|
qcodeInput.value = ''; |
|
|
searchResults.innerHTML = ''; |
|
|
|
|
|
searchModal.style.display = 'flex'; |
|
|
} |
|
|
|
|
|
function closeSearchModal() { |
|
|
searchModal.style.display = 'none'; |
|
|
currentAnnotationForLink = null; |
|
|
} |
|
|
|
|
|
async function searchWikidata() { |
|
|
const query = wikidataSearchInput.value.trim(); |
|
|
if (!query) { |
|
|
showStatus('Please enter a search term.', true); |
|
|
return; |
|
|
} |
|
|
|
|
|
wikidataSearchBtn.disabled = true; |
|
|
wikidataSearchBtn.textContent = 'Searching...'; |
|
|
searchResults.innerHTML = '<div style="padding: 20px; text-align: center; color: #6b7280;">Searching Wikidata...</div>'; |
|
|
|
|
|
try { |
|
|
const response = await fetch('/search_wikidata', { |
|
|
method: 'POST', |
|
|
headers: { |
|
|
'Content-Type': 'application/json', |
|
|
}, |
|
|
body: JSON.stringify({ |
|
|
query: query, |
|
|
limit: 10 |
|
|
}) |
|
|
}); |
|
|
|
|
|
if (!response.ok) { |
|
|
throw new Error('Search failed'); |
|
|
} |
|
|
|
|
|
const data = await response.json(); |
|
|
|
|
|
if (data.results.length === 0) { |
|
|
searchResults.innerHTML = '<div style="padding: 20px; text-align: center; color: #6b7280;">No results found. Try a different search term.</div>'; |
|
|
} else { |
|
|
searchResults.innerHTML = data.results.map(result => ` |
|
|
<div class="search-result-item" onclick="selectWikidataEntity('${result.id}', '${result.label.replace(/'/g, "\\'")}', '${(result.description || '').replace(/'/g, "\\'")}', '${result.url}')"> |
|
|
<div class="search-result-label">${result.label}</div> |
|
|
<div class="search-result-qcode">${result.id}</div> |
|
|
${result.description ? `<div class="search-result-description">${result.description}</div>` : ''} |
|
|
</div> |
|
|
`).join(''); |
|
|
} |
|
|
|
|
|
} catch (error) { |
|
|
console.error('Error searching Wikidata:', error); |
|
|
searchResults.innerHTML = '<div style="padding: 20px; text-align: center; color: #ef4444;">Search failed. Please try again.</div>'; |
|
|
} finally { |
|
|
wikidataSearchBtn.disabled = false; |
|
|
wikidataSearchBtn.textContent = 'Search'; |
|
|
} |
|
|
} |
|
|
|
|
|
async function linkQcode() { |
|
|
const qcode = qcodeInput.value.trim(); |
|
|
if (!qcode) { |
|
|
showStatus('Please enter a Q-code.', true); |
|
|
return; |
|
|
} |
|
|
|
|
|
qcodeBtn.disabled = true; |
|
|
qcodeBtn.textContent = 'Linking...'; |
|
|
|
|
|
try { |
|
|
const response = await fetch('/get_wikidata_entity', { |
|
|
method: 'POST', |
|
|
headers: { |
|
|
'Content-Type': 'application/json', |
|
|
}, |
|
|
body: JSON.stringify({ |
|
|
qcode: qcode |
|
|
}) |
|
|
}); |
|
|
|
|
|
if (!response.ok) { |
|
|
const errorData = await response.json(); |
|
|
throw new Error(errorData.error || 'Failed to get entity'); |
|
|
} |
|
|
|
|
|
const data = await response.json(); |
|
|
selectWikidataEntity(data.entity.id, data.entity.label, data.entity.description, data.entity.url); |
|
|
|
|
|
} catch (error) { |
|
|
console.error('Error linking Q-code:', error); |
|
|
showStatus(`Error: ${error.message}`, true); |
|
|
} finally { |
|
|
qcodeBtn.disabled = false; |
|
|
qcodeBtn.textContent = 'Link Q-code'; |
|
|
} |
|
|
} |
|
|
|
|
|
function selectWikidataEntity(id, label, description, url) { |
|
|
if (!currentAnnotationForLink) return; |
|
|
|
|
|
// Link the annotation to Wikidata |
|
|
currentAnnotationForLink.wikidata = { |
|
|
id: id, |
|
|
label: label, |
|
|
description: description, |
|
|
url: url |
|
|
}; |
|
|
|
|
|
updateSidebarAnnotations(); |
|
|
closeSearchModal(); |
|
|
|
|
|
showStatus(`Linked "${currentAnnotationForLink.text}" to ${id}: ${label}`); |
|
|
} |
|
|
|
|
|
function unlinkAnnotation(annotationId) { |
|
|
const annotation = annotations.find(ann => ann.id === annotationId); |
|
|
if (!annotation) return; |
|
|
|
|
|
const entityLabel = annotation.wikidata ? annotation.wikidata.label : 'entity'; |
|
|
delete annotation.wikidata; |
|
|
|
|
|
updateSidebarAnnotations(); |
|
|
showStatus(`Unlinked "${annotation.text}" from ${entityLabel}`); |
|
|
} |
|
|
|
|
|
async function autoLinkAnnotation(annotation) { |
|
|
if (!autoLinkCheckbox.checked || annotation.wikidata) { |
|
|
return; // Skip if auto-link is disabled or already linked |
|
|
} |
|
|
|
|
|
try { |
|
|
const response = await fetch('/search_wikidata', { |
|
|
method: 'POST', |
|
|
headers: { |
|
|
'Content-Type': 'application/json', |
|
|
}, |
|
|
body: JSON.stringify({ |
|
|
query: annotation.text, |
|
|
limit: 1 // Only get the first result |
|
|
}) |
|
|
}); |
|
|
|
|
|
if (!response.ok) { |
|
|
console.error('Auto-link search failed for:', annotation.text); |
|
|
return; |
|
|
} |
|
|
|
|
|
const data = await response.json(); |
|
|
|
|
|
if (data.results.length > 0) { |
|
|
const result = data.results[0]; |
|
|
annotation.wikidata = { |
|
|
id: result.id, |
|
|
label: result.label, |
|
|
description: result.description, |
|
|
url: result.url |
|
|
}; |
|
|
|
|
|
console.log(`Auto-linked "${annotation.text}" to ${result.id}: ${result.label}`); |
|
|
} |
|
|
} catch (error) { |
|
|
console.error('Error auto-linking annotation:', annotation.text, error); |
|
|
} |
|
|
} |
|
|
|
|
|
async function autoLinkAllAnnotations() { |
|
|
if (!autoLinkCheckbox.checked) { |
|
|
return; |
|
|
} |
|
|
|
|
|
let linkedCount = 0; |
|
|
const unlinkedAnnotations = annotations.filter(ann => !ann.wikidata); |
|
|
|
|
|
for (const annotation of unlinkedAnnotations) { |
|
|
await autoLinkAnnotation(annotation); |
|
|
if (annotation.wikidata) { |
|
|
linkedCount++; |
|
|
} |
|
|
} |
|
|
|
|
|
if (linkedCount > 0) { |
|
|
updateSidebarAnnotations(); |
|
|
showStatus(`Auto-linked ${linkedCount} annotation${linkedCount > 1 ? 's' : ''} to Wikidata`); |
|
|
} |
|
|
} |
|
|
|
|
|
// Event listeners |
|
|
processBtn.addEventListener('click', processText); |
|
|
textViewer.addEventListener('mouseup', handleTextSelection); |
|
|
textViewer.addEventListener('touchend', handleTextSelection); |
|
|
|
|
|
// Label management event listeners |
|
|
labelsGrid.addEventListener('click', (e) => { |
|
|
const labelItem = e.target.closest('.label-item'); |
|
|
if (labelItem && !e.target.classList.contains('delete-label')) { |
|
|
const labelName = labelItem.dataset.label; |
|
|
selectLabel(labelName); |
|
|
} |
|
|
}); |
|
|
|
|
|
addLabelBtn.addEventListener('click', addLabel); |
|
|
|
|
|
labelInput.addEventListener('keypress', (e) => { |
|
|
if (e.key === 'Enter') { |
|
|
addLabel(); |
|
|
} |
|
|
}); |
|
|
|
|
|
glinerBtn.addEventListener('click', runGliner); |
|
|
|
|
|
// Wikidata event listeners |
|
|
wikidataSearchBtn.addEventListener('click', searchWikidata); |
|
|
qcodeBtn.addEventListener('click', linkQcode); |
|
|
|
|
|
wikidataSearchInput.addEventListener('keypress', (e) => { |
|
|
if (e.key === 'Enter') { |
|
|
searchWikidata(); |
|
|
} |
|
|
}); |
|
|
|
|
|
qcodeInput.addEventListener('keypress', (e) => { |
|
|
if (e.key === 'Enter') { |
|
|
linkQcode(); |
|
|
} |
|
|
}); |
|
|
|
|
|
// Close modal when clicking outside |
|
|
searchModal.addEventListener('click', (e) => { |
|
|
if (e.target === searchModal) { |
|
|
closeSearchModal(); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
// Tab switching |
|
|
function switchTab(tabName) { |
|
|
currentActiveTab = tabName; |
|
|
|
|
|
// Update tab buttons |
|
|
document.querySelectorAll('.tab').forEach(tab => tab.classList.remove('active')); |
|
|
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active')); |
|
|
|
|
|
if (tabName === 'entities') { |
|
|
document.querySelector('.tab[onclick*="entities"]').classList.add('active'); |
|
|
document.getElementById('entitiesTab').classList.add('active'); |
|
|
} else { |
|
|
document.querySelector('.tab[onclick*="relationships"]').classList.add('active'); |
|
|
document.getElementById('relationshipsTab').classList.add('active'); |
|
|
// Ensure relationship labels are loaded when switching to relationships tab |
|
|
if (availableRelationshipLabels.length === 0) { |
|
|
loadDefaultRelationshipLabels(); |
|
|
} |
|
|
// Update the form regardless |
|
|
updateRelationshipForm(); |
|
|
} |
|
|
} |
|
|
|
|
|
// Relationship functions |
|
|
async function loadDefaultRelationshipLabels() { |
|
|
try { |
|
|
const response = await fetch('/get_default_relationship_labels'); |
|
|
const data = await response.json(); |
|
|
|
|
|
availableRelationshipLabels = data.relationship_labels.map(label => ({ |
|
|
...label, |
|
|
isDefault: true |
|
|
})); |
|
|
|
|
|
renderRelationshipLabels(); |
|
|
updateExtractRelationshipsButton(); |
|
|
// Update the relationship form dropdowns |
|
|
updateRelationshipForm(); |
|
|
} catch (error) { |
|
|
console.error('Error loading default relationship labels:', error); |
|
|
showStatus('Error loading relationship labels.', true); |
|
|
} |
|
|
} |
|
|
|
|
|
function renderRelationshipLabels() { |
|
|
relationshipLabelsGrid.innerHTML = availableRelationshipLabels.map(label => ` |
|
|
<div class="label-item ${!label.isDefault ? 'custom' : ''}" |
|
|
data-label="${label.name}" |
|
|
style="background-color: ${label.color}; border-color: ${label.border}; color: ${label.border};"> |
|
|
${label.name} |
|
|
${!label.isDefault ? `<button class="delete-label" onclick="deleteRelationshipLabel('${label.name}')" title="Delete label">×</button>` : ''} |
|
|
</div> |
|
|
`).join(''); |
|
|
} |
|
|
|
|
|
function addRelationshipLabel() { |
|
|
const name = relationshipLabelInput.value.trim().toLowerCase(); |
|
|
const color = relationshipColorInput.value; |
|
|
|
|
|
if (!name) { |
|
|
showStatus('Please enter a relationship name.', true); |
|
|
return; |
|
|
} |
|
|
|
|
|
if (availableRelationshipLabels.some(l => l.name === name)) { |
|
|
showStatus('Relationship label already exists.', true); |
|
|
return; |
|
|
} |
|
|
|
|
|
const borderColor = adjustBrightness(color, -40); |
|
|
|
|
|
const newLabel = { |
|
|
name: name, |
|
|
color: color + '40', |
|
|
border: borderColor, |
|
|
isDefault: false |
|
|
}; |
|
|
|
|
|
availableRelationshipLabels.push(newLabel); |
|
|
renderRelationshipLabels(); |
|
|
updateExtractRelationshipsButton(); |
|
|
|
|
|
relationshipLabelInput.value = ''; |
|
|
relationshipColorInput.value = '#e879f9'; |
|
|
|
|
|
showStatus(`Relationship "${name}" added successfully!`); |
|
|
} |
|
|
|
|
|
function deleteRelationshipLabel(labelName) { |
|
|
if (confirm(`Delete relationship label "${labelName}"?`)) { |
|
|
availableRelationshipLabels = availableRelationshipLabels.filter(l => l.name !== labelName); |
|
|
relationships = relationships.filter(r => r.relation_type !== labelName); |
|
|
|
|
|
renderRelationshipLabels(); |
|
|
updateSidebarRelationships(); |
|
|
updateExtractRelationshipsButton(); |
|
|
showStatus(`Relationship label "${labelName}" deleted.`); |
|
|
} |
|
|
} |
|
|
|
|
|
function updateExtractRelationshipsButton() { |
|
|
const hasText = originalText && originalText !== 'Process your text to start annotating...'; |
|
|
const hasRelationshipLabels = availableRelationshipLabels.length > 0; |
|
|
const hasEntities = annotations.length > 0; |
|
|
|
|
|
extractRelationshipsBtn.disabled = !hasText || !hasRelationshipLabels; |
|
|
|
|
|
if (!hasText) { |
|
|
extractRelationshipsBtn.innerHTML = '<span>🔗</span> Process Text First'; |
|
|
extractRelationshipsBtn.title = 'Process text first'; |
|
|
} else if (!hasEntities) { |
|
|
extractRelationshipsBtn.innerHTML = '<span>🔗</span> Run GLiNER Entities First'; |
|
|
extractRelationshipsBtn.title = 'Extract entities first to identify relationship participants'; |
|
|
} else if (!hasRelationshipLabels) { |
|
|
extractRelationshipsBtn.innerHTML = '<span>🔗</span> Add Relationship Labels'; |
|
|
extractRelationshipsBtn.title = 'Add some relationship labels first'; |
|
|
} else { |
|
|
extractRelationshipsBtn.innerHTML = '<span>🔗</span> Extract Relationships'; |
|
|
extractRelationshipsBtn.title = 'Extract relationships using GLiNER'; |
|
|
} |
|
|
} |
|
|
|
|
|
function getUniqueEntityLabels() { |
|
|
// Get unique entity labels from extracted annotations |
|
|
const entityLabels = [...new Set(annotations.map(ann => ann.label))]; |
|
|
console.log('Unique entity labels found:', entityLabels); |
|
|
return entityLabels; |
|
|
} |
|
|
|
|
|
function updateEntityLabelsDisplay() { |
|
|
const entityLabels = getUniqueEntityLabels(); |
|
|
if (entityLabels.length === 0) { |
|
|
entityLabelsDisplay.innerHTML = 'Run GLiNER entities first to see available entity types'; |
|
|
entityLabelsDisplay.style.color = '#6b7280'; |
|
|
} else { |
|
|
entityLabelsDisplay.innerHTML = entityLabels.map(label => |
|
|
`<span style="background: #e5e7eb; color: #374151; padding: 2px 6px; border-radius: 3px; margin-right: 4px; font-size: 11px;">${label}</span>` |
|
|
).join(''); |
|
|
entityLabelsDisplay.style.color = '#374151'; |
|
|
} |
|
|
} |
|
|
|
|
|
async function extractRelationships() { |
|
|
const text = originalText; |
|
|
const relationshipLabelNames = availableRelationshipLabels.map(label => label.name); |
|
|
|
|
|
if (!text || text === 'Process your text to start annotating...') { |
|
|
showStatus('Please process text first.', true); |
|
|
return; |
|
|
} |
|
|
|
|
|
if (relationshipLabelNames.length === 0) { |
|
|
showStatus('Please add some relationship labels first.', true); |
|
|
return; |
|
|
} |
|
|
|
|
|
// Check if we have entities, if not, run GLiNER first |
|
|
if (annotations.length === 0) { |
|
|
showStatus('No entities found. Running GLiNER entities first...', false); |
|
|
extractRelationshipsBtn.disabled = true; |
|
|
extractRelationshipsBtn.innerHTML = '<span>⏳</span> Extracting Entities First...'; |
|
|
|
|
|
try { |
|
|
await runGliner(); |
|
|
if (annotations.length === 0) { |
|
|
showStatus('No entities found in text. Cannot extract relationships.', true); |
|
|
extractRelationshipsBtn.disabled = false; |
|
|
extractRelationshipsBtn.innerHTML = '<span>🔗</span> Extract Relationships'; |
|
|
return; |
|
|
} |
|
|
} catch (error) { |
|
|
showStatus('Failed to extract entities. Please try again.', true); |
|
|
extractRelationshipsBtn.disabled = false; |
|
|
extractRelationshipsBtn.innerHTML = '<span>🔗</span> Extract Relationships'; |
|
|
return; |
|
|
} |
|
|
} |
|
|
|
|
|
extractRelationshipsBtn.disabled = true; |
|
|
extractRelationshipsBtn.innerHTML = '<span>⏳</span> Extracting Relationships...'; |
|
|
relationshipResults.style.display = 'none'; |
|
|
|
|
|
try { |
|
|
const response = await fetch('/run_gliner_relationships', { |
|
|
method: 'POST', |
|
|
headers: { |
|
|
'Content-Type': 'application/json', |
|
|
}, |
|
|
body: JSON.stringify({ |
|
|
text: text, |
|
|
relationship_labels: relationshipLabelNames, |
|
|
entity_labels: getUniqueEntityLabels() |
|
|
}) |
|
|
}); |
|
|
|
|
|
if (!response.ok) { |
|
|
throw new Error('Failed to extract relationships'); |
|
|
} |
|
|
|
|
|
const data = await response.json(); |
|
|
|
|
|
let addedCount = 0; |
|
|
data.relationships.forEach(rel => { |
|
|
const exists = relationships.some(r => |
|
|
r.subject === rel.subject && |
|
|
r.object === rel.object && |
|
|
r.relation_type === rel.relation_type |
|
|
); |
|
|
|
|
|
if (!exists) { |
|
|
const relationship = { |
|
|
id: ++relationshipCounter, |
|
|
subject: rel.subject, |
|
|
subject_start: rel.subject_start, |
|
|
subject_end: rel.subject_end, |
|
|
relation_type: rel.relation_type, |
|
|
relation_text: rel.relation_text, |
|
|
relation_start: rel.relation_start, |
|
|
relation_end: rel.relation_end, |
|
|
object: rel.object, |
|
|
object_start: rel.object_start, |
|
|
object_end: rel.object_end, |
|
|
confidence: rel.confidence, |
|
|
full_text: rel.full_text |
|
|
}; |
|
|
relationships.push(relationship); |
|
|
addedCount++; |
|
|
} |
|
|
}); |
|
|
|
|
|
updateSidebarRelationships(); |
|
|
|
|
|
relationshipResults.innerHTML = `Found ${data.total_found} relationships, added ${addedCount} new ones`; |
|
|
relationshipResults.style.display = 'block'; |
|
|
relationshipResults.style.color = addedCount > 0 ? '#065f46' : '#6b7280'; |
|
|
|
|
|
showStatus(`Relationship extraction completed! Found ${data.total_found} relationships, added ${addedCount} new ones.`); |
|
|
|
|
|
} catch (error) { |
|
|
showStatus('Error extracting relationships. Please try again.', true); |
|
|
console.error('Error:', error); |
|
|
relationshipResults.innerHTML = 'Error occurred during processing'; |
|
|
relationshipResults.style.display = 'block'; |
|
|
relationshipResults.style.color = '#dc2626'; |
|
|
} finally { |
|
|
extractRelationshipsBtn.disabled = false; |
|
|
extractRelationshipsBtn.innerHTML = '<span>🔗</span> Extract Relationships'; |
|
|
updateExtractRelationshipsButton(); |
|
|
} |
|
|
} |
|
|
|
|
|
function updateSidebarRelationships() { |
|
|
// Update dropdowns |
|
|
updateEntityDropdowns(); |
|
|
updateRelationshipForm(); |
|
|
|
|
|
if (relationships.length === 0) { |
|
|
sidebarRelationships.innerHTML = '<p style="color: #6b7280; font-style: italic; text-align: center; margin-top: 40px;">No relationships yet.<br><br>Add relationship labels and extract relationships from your text, or create them manually above.</p>'; |
|
|
return; |
|
|
} |
|
|
|
|
|
sidebarRelationships.innerHTML = relationships.map(relationship => { |
|
|
const label = availableRelationshipLabels.find(l => l.name === relationship.relation_type); |
|
|
const labelStyle = label ? `background-color: ${label.color}; border-color: ${label.border}; color: ${label.border};` : ''; |
|
|
|
|
|
const objectText = (relationship.object && relationship.object.trim()) || relationship.object_text || '[unknown object]'; |
|
|
const displayText = `${relationship.subject} → ${relationship.relation_type} → ${objectText}`; |
|
|
|
|
|
return ` |
|
|
<div class="relationship-card" data-relationship-id="${relationship.id}"> |
|
|
<div class="relationship-header"> |
|
|
<div class="relationship-text">${displayText}</div> |
|
|
<div class="relationship-controls"> |
|
|
<button class="reverse-btn" onclick="editRelationship(${relationship.id})" title="Edit relationship">✏️</button> |
|
|
<button class="reverse-btn" onclick="reverseRelationship(${relationship.id})" title="Reverse direction">⇄</button> |
|
|
<button class="delete-rel-btn" onclick="deleteRelationship(${relationship.id})" title="Delete relationship">×</button> |
|
|
</div> |
|
|
</div> |
|
|
<div class="relationship-info"> |
|
|
<span class="annotation-label-badge" style="${labelStyle}">${relationship.relation_type}</span> |
|
|
<strong>Subject:</strong> "${relationship.subject}" (${relationship.subject_start}-${relationship.subject_end}) |
|
|
${(relationship.object && relationship.object.trim()) ? `<br><strong>Object:</strong> "${relationship.object}" (${relationship.object_start}-${relationship.object_end})` : '<br><strong>Object:</strong> Not detected'} |
|
|
</div> |
|
|
|
|
|
<div id="editor-${relationship.id}" class="relationship-editor"> |
|
|
<div class="relationship-triplet"> |
|
|
<select id="edit-subject-${relationship.id}" class="entity-dropdown"> |
|
|
${getEntityOptions(relationship.subject)} |
|
|
</select> |
|
|
<div class="relation-arrow">→</div> |
|
|
<select id="edit-relation-${relationship.id}" class="relation-dropdown"> |
|
|
${getRelationOptions(relationship.relation_type)} |
|
|
</select> |
|
|
<div class="relation-arrow">→</div> |
|
|
<select id="edit-object-${relationship.id}" class="entity-dropdown"> |
|
|
${getEntityOptions(relationship.object)} |
|
|
</select> |
|
|
</div> |
|
|
<div class="edit-controls"> |
|
|
<button class="save-btn" onclick="saveRelationshipEdit(${relationship.id})">Save</button> |
|
|
<button class="cancel-btn" onclick="cancelRelationshipEdit(${relationship.id})">Cancel</button> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
`; |
|
|
}).join(''); |
|
|
} |
|
|
|
|
|
function getEntityOptions(selectedEntity = '') { |
|
|
const entities = annotations.map(ann => ann.text); |
|
|
const uniqueEntities = [...new Set(entities)]; |
|
|
|
|
|
return uniqueEntities.map(entity => |
|
|
`<option value="${entity}" ${entity === selectedEntity ? 'selected' : ''}>${entity}</option>` |
|
|
).join(''); |
|
|
} |
|
|
|
|
|
function getRelationOptions(selectedRelation = '') { |
|
|
return availableRelationshipLabels.map(rel => |
|
|
`<option value="${rel.name}" ${rel.name === selectedRelation ? 'selected' : ''}>${rel.name}</option>` |
|
|
).join(''); |
|
|
} |
|
|
|
|
|
function updateEntityDropdowns() { |
|
|
const entities = annotations.map(ann => ann.text); |
|
|
const uniqueEntities = [...new Set(entities)]; |
|
|
|
|
|
// Save current selected values |
|
|
const currentSubject = newSubjectSelect.value; |
|
|
const currentObject = newObjectSelect.value; |
|
|
|
|
|
// Update new relationship dropdowns |
|
|
const subjectOptions = '<option value="">Select Subject</option>' + |
|
|
uniqueEntities.map(entity => `<option value="${entity}">${entity}</option>`).join(''); |
|
|
const objectOptions = '<option value="">Select Object</option>' + |
|
|
uniqueEntities.map(entity => `<option value="${entity}">${entity}</option>`).join(''); |
|
|
|
|
|
newSubjectSelect.innerHTML = subjectOptions; |
|
|
newObjectSelect.innerHTML = objectOptions; |
|
|
|
|
|
// Restore selected values if they still exist |
|
|
if (currentSubject && uniqueEntities.includes(currentSubject)) { |
|
|
newSubjectSelect.value = currentSubject; |
|
|
} |
|
|
if (currentObject && uniqueEntities.includes(currentObject)) { |
|
|
newObjectSelect.value = currentObject; |
|
|
} |
|
|
} |
|
|
|
|
|
function updateRelationshipForm() { |
|
|
// Save current selected values before updating |
|
|
const currentSubject = newSubjectSelect.value; |
|
|
const currentRelation = newRelationSelect.value; |
|
|
const currentObject = newObjectSelect.value; |
|
|
|
|
|
// Update relationship dropdown |
|
|
const relationOptions = '<option value="">Select Relation</option>' + |
|
|
availableRelationshipLabels.map(rel => `<option value="${rel.name}">${rel.name}</option>`).join(''); |
|
|
|
|
|
newRelationSelect.innerHTML = relationOptions; |
|
|
|
|
|
// Restore the selected value if it still exists in the options |
|
|
if (currentRelation && availableRelationshipLabels.some(rel => rel.name === currentRelation)) { |
|
|
newRelationSelect.value = currentRelation; |
|
|
} |
|
|
|
|
|
// Ensure dropdown is not disabled |
|
|
newRelationSelect.disabled = false; |
|
|
|
|
|
// Enable/disable add button |
|
|
const hasSubject = newSubjectSelect.value !== ''; |
|
|
const hasRelation = newRelationSelect.value !== ''; |
|
|
const hasObject = newObjectSelect.value !== ''; |
|
|
|
|
|
addRelationshipBtn.disabled = !(hasSubject && hasRelation && hasObject); |
|
|
|
|
|
// Update button text to show status |
|
|
if (availableRelationshipLabels.length === 0) { |
|
|
addRelationshipBtn.textContent = 'Add Relationship Labels First'; |
|
|
addRelationshipBtn.disabled = true; |
|
|
} else if (annotations.length === 0) { |
|
|
addRelationshipBtn.textContent = 'Add Entities First'; |
|
|
addRelationshipBtn.disabled = true; |
|
|
} else if (hasSubject && hasRelation && hasObject) { |
|
|
addRelationshipBtn.textContent = 'Add Relationship'; |
|
|
addRelationshipBtn.disabled = false; |
|
|
} else { |
|
|
addRelationshipBtn.textContent = 'Select All Fields'; |
|
|
addRelationshipBtn.disabled = true; |
|
|
} |
|
|
} |
|
|
|
|
|
function changeRelationshipType(relationshipId, newType) { |
|
|
const relationship = relationships.find(r => r.id === relationshipId); |
|
|
if (relationship) { |
|
|
relationship.relation_type = newType; |
|
|
updateSidebarRelationships(); |
|
|
showStatus(`Updated relationship to "${newType}"`); |
|
|
} |
|
|
} |
|
|
|
|
|
function reverseRelationship(relationshipId) { |
|
|
const relationship = relationships.find(r => r.id === relationshipId); |
|
|
if (relationship && relationship.object) { |
|
|
// Swap subject and object |
|
|
const tempSubject = relationship.subject; |
|
|
const tempSubjectStart = relationship.subject_start; |
|
|
const tempSubjectEnd = relationship.subject_end; |
|
|
|
|
|
relationship.subject = relationship.object; |
|
|
relationship.subject_start = relationship.object_start; |
|
|
relationship.subject_end = relationship.object_end; |
|
|
|
|
|
relationship.object = tempSubject; |
|
|
relationship.object_start = tempSubjectStart; |
|
|
relationship.object_end = tempSubjectEnd; |
|
|
|
|
|
// Update the full text |
|
|
relationship.full_text = `${relationship.subject} ${relationship.relation_type} ${relationship.object}`; |
|
|
|
|
|
updateSidebarRelationships(); |
|
|
showStatus(`Reversed relationship: ${relationship.full_text}`); |
|
|
} else { |
|
|
showStatus('Cannot reverse relationship without both subject and object', true); |
|
|
} |
|
|
} |
|
|
|
|
|
function editRelationship(relationshipId) { |
|
|
// Hide all other editors |
|
|
document.querySelectorAll('.relationship-editor').forEach(editor => { |
|
|
editor.classList.remove('active'); |
|
|
}); |
|
|
|
|
|
// Show this editor |
|
|
const editor = document.getElementById(`editor-${relationshipId}`); |
|
|
if (editor) { |
|
|
editor.classList.add('active'); |
|
|
} |
|
|
} |
|
|
|
|
|
function saveRelationshipEdit(relationshipId) { |
|
|
const relationship = relationships.find(r => r.id === relationshipId); |
|
|
if (!relationship) return; |
|
|
|
|
|
const newSubject = document.getElementById(`edit-subject-${relationshipId}`).value; |
|
|
const newRelation = document.getElementById(`edit-relation-${relationshipId}`).value; |
|
|
const newObject = document.getElementById(`edit-object-${relationshipId}`).value; |
|
|
|
|
|
if (!newSubject || !newRelation || !newObject) { |
|
|
showStatus('Please select all fields', true); |
|
|
return; |
|
|
} |
|
|
|
|
|
// Find the annotation data for the entities |
|
|
const subjectAnnotation = annotations.find(ann => ann.text === newSubject); |
|
|
const objectAnnotation = annotations.find(ann => ann.text === newObject); |
|
|
|
|
|
// Update relationship |
|
|
relationship.subject = newSubject; |
|
|
relationship.relation_type = newRelation; |
|
|
relationship.object = newObject; |
|
|
relationship.full_text = `${newSubject} ${newRelation} ${newObject}`; |
|
|
|
|
|
if (subjectAnnotation) { |
|
|
relationship.subject_start = subjectAnnotation.start; |
|
|
relationship.subject_end = subjectAnnotation.end; |
|
|
} |
|
|
|
|
|
if (objectAnnotation) { |
|
|
relationship.object_start = objectAnnotation.start; |
|
|
relationship.object_end = objectAnnotation.end; |
|
|
} |
|
|
|
|
|
// Hide editor and update display |
|
|
document.getElementById(`editor-${relationshipId}`).classList.remove('active'); |
|
|
updateSidebarRelationships(); |
|
|
showStatus(`Updated relationship: ${relationship.full_text}`); |
|
|
} |
|
|
|
|
|
function cancelRelationshipEdit(relationshipId) { |
|
|
document.getElementById(`editor-${relationshipId}`).classList.remove('active'); |
|
|
} |
|
|
|
|
|
function addNewRelationship() { |
|
|
const subject = newSubjectSelect.value; |
|
|
const relation = newRelationSelect.value; |
|
|
const object = newObjectSelect.value; |
|
|
|
|
|
if (!subject || !relation || !object) { |
|
|
showStatus('Please select all fields', true); |
|
|
return; |
|
|
} |
|
|
|
|
|
// Check if relationship already exists |
|
|
const exists = relationships.some(r => |
|
|
r.subject === subject && |
|
|
r.object === object && |
|
|
r.relation_type === relation |
|
|
); |
|
|
|
|
|
if (exists) { |
|
|
showStatus('This relationship already exists', true); |
|
|
return; |
|
|
} |
|
|
|
|
|
// Find the annotation data for the entities |
|
|
const subjectAnnotation = annotations.find(ann => ann.text === subject); |
|
|
const objectAnnotation = annotations.find(ann => ann.text === object); |
|
|
|
|
|
// Create new relationship |
|
|
const newRelationship = { |
|
|
id: ++relationshipCounter, |
|
|
subject: subject, |
|
|
subject_start: subjectAnnotation ? subjectAnnotation.start : -1, |
|
|
subject_end: subjectAnnotation ? subjectAnnotation.end : -1, |
|
|
relation_type: relation, |
|
|
relation_text: relation, |
|
|
relation_start: -1, |
|
|
relation_end: -1, |
|
|
object: object, |
|
|
object_start: objectAnnotation ? objectAnnotation.start : -1, |
|
|
object_end: objectAnnotation ? objectAnnotation.end : -1, |
|
|
confidence: 1.0, |
|
|
full_text: `${subject} ${relation} ${object}` |
|
|
}; |
|
|
|
|
|
relationships.push(newRelationship); |
|
|
|
|
|
// Reset form |
|
|
newSubjectSelect.value = ''; |
|
|
newRelationSelect.value = ''; |
|
|
newObjectSelect.value = ''; |
|
|
|
|
|
updateSidebarRelationships(); |
|
|
showStatus(`Added relationship: ${newRelationship.full_text}`); |
|
|
} |
|
|
|
|
|
function deleteRelationship(relationshipId) { |
|
|
const relationshipIndex = relationships.findIndex(r => r.id === relationshipId); |
|
|
if (relationshipIndex !== -1) { |
|
|
const deletedRelationship = relationships[relationshipIndex]; |
|
|
relationships.splice(relationshipIndex, 1); |
|
|
|
|
|
updateSidebarRelationships(); |
|
|
showStatus(`Deleted relationship: ${deletedRelationship.full_text}`); |
|
|
} |
|
|
} |
|
|
|
|
|
// Event listeners for relationships |
|
|
addRelationshipLabelBtn.addEventListener('click', addRelationshipLabel); |
|
|
|
|
|
relationshipLabelInput.addEventListener('keypress', (e) => { |
|
|
if (e.key === 'Enter') { |
|
|
addRelationshipLabel(); |
|
|
} |
|
|
}); |
|
|
|
|
|
extractRelationshipsBtn.addEventListener('click', extractRelationships); |
|
|
|
|
|
// Event listeners for manual relationship creation |
|
|
addRelationshipBtn.addEventListener('click', addNewRelationship); |
|
|
|
|
|
newSubjectSelect.addEventListener('change', updateRelationshipForm); |
|
|
newRelationSelect.addEventListener('change', updateRelationshipForm); |
|
|
newObjectSelect.addEventListener('change', updateRelationshipForm); |
|
|
|
|
|
|
|
|
// Initialize |
|
|
loadDefaultLabels(); |
|
|
loadDefaultRelationshipLabels(); |
|
|
updateAnnotationsList(); |
|
|
</script> |
|
|
</body> |
|
|
</html> |
|
|
|
|
|
|