diff --git a/apps/dicom-connect/table.css b/apps/dicom-connect/table.css new file mode 100644 index 000000000..786b61235 --- /dev/null +++ b/apps/dicom-connect/table.css @@ -0,0 +1,260 @@ +html, +body { + width: 100%; + height: 100%; + min-height: 100%; + font-family: Arial, Helvetica, sans-serif; +} + +footer { + /* position: sticky; + /* bottom: -8px; */ + width: 100%; +} +.header { + margin-top: 60px; +} + +.page-container { + height: 100vh; + /* flex-direction: column; */ + /* justify-content: space-between; */ +} + +.link { + letter-spacing: 0.02em; + text-transform: uppercase; + font-size: 0.99em; + font-weight: bold; + color: #5e6875; + text-decoration: none; + padding-right: 0.7rem; + padding-left: 0.7rem; + margin-top: 0.2rem; + font-family: "Open Sans", Helvetica, sans-serif; + /* line-height: 1.8; */ +} +.bg-dark { + background-color: #343a40 !important; +} +.bg-info { + background-color: #17a2b8 !important; +} +#collection-list li.item { + cursor: pointer; +} +#collection-list li.item:hover { + background-color: #deeeff; +} +#entries { + cursor: pointer; +} +.page-item { + cursor: pointer; +} + +/* #collection-list .item:hover { + background-color:#f8f9fa; +} */ + +#collection-list .item { + /* font-weight:bold; */ + color: #007bff; + padding: 0.5rem; +} +#collection-list .item i { + padding: 0.25rem; +} + +nav li { + transition: background 0.5s; + border-radius: 3px; +} +nav li:not(:first-child) { + margin-left: 0.3em !important; +} +nav li:not(.active):hover { + background: white; +} +nav li:not(.active):hover a { + color: black !important; +} +.active { + background: white; +} +.active a { + color: black !important; +} + +nav li:not(.active):hover{ + background: white; +} + +.overall { + display: flex; +} + +.reload { + display: none; + padding: 0 4px; +} + +.btn2{ + display: flex; + align-items: center; +} +@media screen and (min-width: 480px){ + .reload { + display: block; + } +} + +#deleteBtn, +#downloadBtn { + margin-left: 0.6em; +} +#deleteBtn i { + color: white; +} +#open-delete { + display: inline-flex; +} +.custom-file-input, +.sort-btn { + cursor: pointer; +} + +#notification-box { + overflow-y: scroll; + max-height: 40em; +} + +/* Tooltip container */ +.tooltipCustom { + position: relative; + display: inline-block; + /* border-bottom: 1px dotted black; If you want dots under the hoverable text */ +} + +/* Tooltip text */ +.tooltipCustom .tooltiptextCustom { + visibility: hidden; + background-color: black; + color: #fff; + text-align: center; + padding: 5px 0; + border-radius: 6px; + + /* Position the tooltip text */ + position: absolute; + z-index: 100; + width: 200px; + bottom: 100%; + left: 50%; + margin-left: -100px; /* Use half of the width (120/2 = 60), to center the tooltip */ +} + +/* Show the tooltip text when you mouse over the tooltip container */ +.tooltipCustom:hover .tooltiptextCustom { + visibility: visible; + z-index: 1000; +} + +.notification-box { + padding: 10px 0px; + color: black; +} + +#tabs, +#content { + width: 100%; +} + +.bg-gray { + background-color: #eee; +} +@media (max-width: 640px) { + #dropNot { + top: 50px; + left: -16px; + width: 290px; + } + .nav { + display: block; + } + .nav .nav-item, + .nav .nav-item a { + padding-left: 0px; + } + .message { + font-size: 13px; + } +} +#dropNot { + top: 60px; + left: 0px; + right: unset; + width: 460px; + box-shadow: 0px 5px 7px -1px #c1c1c1; + padding-bottom: 0px; + padding: 0px; +} +.dropdown-menu:before { + content: ""; + position: absolute; + top: -20px; + left: 12px; + border: 10px solid #343a40; + border-color: transparent transparent #343a40 transparent; +} + +.has-search .form-control { + padding-left: 2.375rem; +} + +.has-search .form-control-feedback { + position: absolute; + z-index: 2; + display: block; + width: 2.375rem; + height: 2.375rem; + line-height: 2.375rem; + text-align: center; + pointer-events: none; + color: #aaa; +} + +/* .nav-tabs { + display: flex; +} */ + +.collapse.in { + display: inline !important; +} +.p { + margin-bottom: 0; +} + +.icon-center { + text-align: center!important +} +/* .main-container { + max-height: calc(100% - 199px); + overflow-x: hidden; + overflow-y: auto; +} */ + +.loader { + -webkit-animation: spin 2s linear infinite; + animation: spin 2s linear infinite; +} + +@-webkit-keyframes spin { + 0% { -webkit-transform: rotate(0deg); } + 100% { -webkit-transform: rotate(360deg); } +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} \ No newline at end of file diff --git a/apps/dicom-connect/table.html b/apps/dicom-connect/table.html new file mode 100644 index 000000000..3c9a662ca --- /dev/null +++ b/apps/dicom-connect/table.html @@ -0,0 +1,118 @@ + + + + + + + + + + + + + + + + + + + + + + + + + CaMicroscope Data Table + + + + + + + + + + + + + + + + + +
+ + +
+

caMicroscope

+

Digital pathology image viewer with support for human/machine generated annotations and markups.

+
+ +
+
+
+ + + +
+
+
+
+
+ + + + + + + + + diff --git a/apps/dicom-connect/table.js b/apps/dicom-connect/table.js new file mode 100644 index 000000000..785cf4542 --- /dev/null +++ b/apps/dicom-connect/table.js @@ -0,0 +1,357 @@ +/** + * static variables + */ + +const sources = [{ + 'name':'j4care', + 'url':'https://development.j4care.com:11443/dcm4chee-arc/aets/DCM4CHEE/rs' + +},{ + 'name': 'google', + 'url': 'https://dicomwebproxy-bqmq3usc3a-uc.a.run.app/dicomWeb' +}] +// const j4careStudiesUrl = 'https://development.j4care.com:11443/dcm4chee-arc/aets/DCM4CHEE/rs' +// const dicomWebStudiesUrl = 'https://dicomwebproxy-bqmq3usc3a-uc.a.run.app/dicomWeb' + +/** + * global variables + */ +isAllSeriesSynced = false; + +const datatableConfig = { + scrollX: true, + lengthMenu: [ + [10, 25, 50, -1], + [10, 25, 50, 'All'] + ] +} + + +const page_states = { + sources: { + data: [{ + 'name':'j4care', + 'url':'https://development.j4care.com:11443/dcm4chee-arc/aets/DCM4CHEE/rs' + + },{ + 'name': 'google', + 'url': 'https://dicomwebproxy-bqmq3usc3a-uc.a.run.app/dicomWeb' + }], + }, + studies: { + data: null, + }, + series: { + data: null, + }, + instances: { + data: null, + }, + status: 'sources', // 'sources, studies, series, instsances' +} +var studies = [] + + + +function getStudies(baseUrl) { + const url = `${baseUrl}/studies` + return fetch(url).then(resp=>resp.json()); +} + +function getSeries(baseUrl, studyId) { + const url = `${baseUrl}/studies/${studyId}/series` + return fetch(url).then(resp=>resp.json()); +} + +function getInstances(baseUrl, studyId, seriesId) { + const url = `${baseUrl}/studies/${studyId}/series/${seriesId}/instances` + return fetch(url).then(resp=>resp.json()); +} + + + + + +function sanitize(string) { + string = string || ''; + const map = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + '\'': ''', + '/': '/', + }; + const reg = /[&<>"'/]/ig; + return string.replace(reg, (match) => (map[match])); +} + + +function initialize() { + const params = getUrlVars(); + console.log('params') + console.log(params) + // store + const store = new Store('../../data/'); + if(params.status=='studies'&¶ms.source){ + page_states.status = params.status; + }else if(params.status=='series'&¶ms.source&¶ms.studyId) { // series table + page_states.status = params.status; + } else if(params.status=='instances'&¶ms.source&¶ms.studyId&¶ms.seriesId) { // isntasnces table + page_states.status = params.status; + } + + + + // + // + // + switch (page_states.status) { + case 'sources': + $('#breadcrumb').append(``); + function generateLink (data, type, row) { + return `${row.name}`; + } + datatable = $('#datatable').DataTable({ + ... datatableConfig, + 'data': page_states[page_states.status].data, + 'columns': [ + {data: 'name', title: 'Name', render:generateLink} + ] + }); + + break; + case 'studies': + + //get source info + var idx = sources.findIndex(elt=>elt.name==params.source) + var src = sources[idx] + + // create breadcrumb for studies + $('#breadcrumb').append(``); + $('#breadcrumb').append(``); + // get all studies + + getStudies(src.url).then(function(data) { + // mapping and merge + data.forEach(elt=>elt.source=src.name) + page_states[page_states.status].data = data + // ${baseUrl}/studies/${studyId}/series + function generateLink (data, type, row) { + const studyId = row['0020000D']['Value'][0] + return `${studyId}`; + } + datatable = $('#datatable').DataTable({ + ... datatableConfig, + 'data': page_states[page_states.status].data, + 'columns': [ + {data: '0020000D.Value.0', title: 'Study Id', render: generateLink}, + {data: '00100020.Value.0', title: 'Name'}, + {data: 'source', title: 'Source'} + ] + }); + }) + + break; + case 'series': + //get source info + var idx = sources.findIndex(elt=>elt.name==params.source) + var src = sources[idx] + + // create breadcrumb for series + $('#breadcrumb').append(``); + $('#breadcrumb').append(``); + $('#breadcrumb').append(``); + // get all series + + + getSeries(src.url, params.studyId).then(function(data) { + // add source and study id + data.forEach(elt=>{ + elt.source=src.name + elt.url=src.url + elt.studyId=params.studyId + elt.status='searching' // 'searching', 'unsync', 'loading', 'done' + }) + page_states[page_states.status].data = data + function generateLink (data, type, row) { + const seriesId = row['0020000E']['Value'][0] + const modality = row['00080060']['Value'][0] + if (row.status !='done') return seriesId; + const slideId = row.slideId; + //if (modality=='SM') + return `${seriesId}` + + + // return `${seriesId}`; + } + function generateStatus (data, type, row) { + switch (row.status) { + case 'searching': + // return spin + return '
'; + case 'unsync': + // return btn + const seriesId = row['0020000E']['Value'][0]; + const modality = row['00080060']['Value'][0]; + return `
`; // + case 'loading': + // return downloading + // return '
'; + return `
` + case 'done': + // return url + + return '
'; + + default: + + return '
'; + } + } + datatable = $('#datatable').DataTable({ + ... datatableConfig, + 'data': page_states[page_states.status].data, + 'columns': [ + {data: 'status', title: 'Status', render: generateStatus}, + {data: '0020000E.Value.0', title: 'Series Id',render:generateLink }, + {data: '00080060.Value.0', title: 'Modality'}, + {data: 'source', title: 'Source'}, + {data: 'studyId', title: 'study Id'} + + ] + }); + + async function checkInterval() { + const query = { + 'dicom-source-url': src.url, + 'study': params.studyId, + }; + + const slides = await store.findSlide(null, null, params.studyId, null, query); + console.log(slides) + + const data = datatable.data(); + + for (let i = 0; i < data.length; i++) { + const d = data[i]; + const modality = d['00080060']['Value'][0]; + const series = d['0020000E']['Value'][0]; + + if (modality === 'SM') { + const idx = slides.findIndex(slide => series === slide.series); + if (idx !== -1) { + d.status = slides[idx].status; + d.slideId = slides[idx]._id.$oid; + } else { + d.status = 'unsync'; + } + } + + if (modality === 'ANN') { + let annotationQuery = { + 'provenance.image.dicom-source-url': src.url, + 'provenance.image.dicom-study': params.studyId, + 'provenance.image.dicom-series': series + }; + + let annotationCount = await store.countMarks(annotationQuery); + console.info("Counted " + annotationCount[0].count + " mark objects for " + series); + + if (annotationCount[0].count > 0) { + d.status = 'done'; + d.slideId = slides[0]._id.$oid; + } else { + d.status = 'unsync'; + } + } + } + + datatable.rows().invalidate().draw(); + + const series = page_states[page_states.status].data; + + if (series.every(s => s.status !== 'unsync' && s.status !== 'syncing')) { + console.log('clear'); + clearInterval(updateSeriesStatus); + } + + console.log('running'); + } + + // initialize + checkInterval() + // update every 10 seconds + var updateSeriesStatus = setInterval(checkInterval, 10000); + }) + break; + case 'instances': + //get source info + var idx = sources.findIndex(elt=>elt.name==params.source) + var src = sources[idx] + // create breadcrumb for instances + const backSeriesUrl = `../dicom-connect/table.html?source=${params.source}&status=series&studyId=${params.studyId}` + $('#breadcrumb').append(``); + $('#breadcrumb').append(``); + $('#breadcrumb').append(``); + $('#breadcrumb').append(``); + + + + + + getInstances(src.url, params.studyId, params.seriesId).then(function(data) { + // add status + data.forEach(elt=>{ + elt.source=params.source + elt.studyId=params.studyId + elt.seriesId=params.seriesId + + }) + page_states[page_states.status].data = data + function generateLink (data, type, row) { + const {studyId, seriesId, status}= row + const instanceId = row['00080018']['Value'][0] + if (status=='done') return instanceId + return `${instanceId}`; + } + + datatable = $('#datatable').DataTable({ + ... datatableConfig, + 'data': page_states[page_states.status].data, + 'columns': [ + {data: '00080018.Value.0', title: 'Instance Id', render: generateLink}, + {data: 'source', title: 'Source'}, + {data: 'seriesId', title: 'Series Id'}, + {data: 'studyId', title: 'Study Id'}, + + ] + }); + }) + break; + + default: + break; + } +} + + +$(document).ready(function() { + initialize(); +}); + + +async function syncSeries(source_url, study, series, modality) { + console.log(source_url, study, series, modality); + const result = await store.syncSeries('../../', {source_url, study, series, modality}) + console.log('syncSeries:'); + console.log(result); +} + +function checkSeriesStatus() { + const series = page_states[page_states.status].data + series.map() + +} +// table.rows.add( dataset ).draw(). + diff --git a/apps/mini/uicallbacks.js b/apps/mini/uicallbacks.js index e24265d19..4c37458fb 100644 --- a/apps/mini/uicallbacks.js +++ b/apps/mini/uicallbacks.js @@ -1359,7 +1359,8 @@ function locationCallback(layerData) { return; } // locate annotation 3.0 - if (item.data.geometries.features[0].geometry.type == 'Point') { + const geoType = item.data.geometries.features[0].geometry.type; + if (geoType == 'Point'||geoType == 'Circle'||geoType == 'Ellipse') { const bound = item.data.geometries.features[0].bound.coordinates; const center = $CAMIC.viewer.viewport.imageToViewportCoordinates( bound[0], diff --git a/apps/viewer/uicallbacks.js b/apps/viewer/uicallbacks.js index 8f2015fd0..69a7b5e8f 100644 --- a/apps/viewer/uicallbacks.js +++ b/apps/viewer/uicallbacks.js @@ -1547,7 +1547,8 @@ function locationCallback(layerData) { return; } // locate annotation 3.0 - if (item.data.geometries.features[0].geometry.type == 'Point') { + const geoType = item.data.geometries.features[0].geometry.type; + if (geoType == 'Point'||geoType == 'Circle'||geoType == 'Ellipse') { const bound = item.data.geometries.features[0].bound.coordinates; const center = $CAMIC.viewer.viewport.imageToViewportCoordinates( bound[0], diff --git a/common/DrawHelper.js b/common/DrawHelper.js index a72813745..c36ab6c75 100644 --- a/common/DrawHelper.js +++ b/common/DrawHelper.js @@ -82,7 +82,9 @@ caDrawHelper.prototype.drawMultiline = function(ctx,array){ this.drawLine(ctx,array[i-1],array[i]); } } -caDrawHelper.prototype.circle = function(ctx, point, radius){ +caDrawHelper.prototype.circle = function(ctx, point, radius, isPoint=true){ + + const path = new Path(); path.arc( point[0], @@ -90,11 +92,34 @@ caDrawHelper.prototype.circle = function(ctx, point, radius){ radius, 0, 2 * Math.PI ); path.closePath(); - path.strokeAndFill(ctx); - //path.stroke(ctx); + if(isPoint) { + path.strokeAndFill(ctx); + } else { + path.stroke(ctx); + } // return points and path return path; } + +// rotation in radians +caDrawHelper.prototype.ellipse = function(ctx, point, radius, rotation){ + const path = new Path(); + path.ellipse( + point[0], + point[1], + radius[0], + radius[1], + rotation, + 0, + 2 * Math.PI + ); + path.closePath(); + // path.strokeAndFill(ctx); + path.stroke(ctx); + // return points and path + return path; +} + caDrawHelper.prototype.drawMultiGrid = function(ctx, points, size){ const path = new Path(); points.forEach(p=>{ @@ -125,25 +150,6 @@ caDrawHelper.prototype.drawLine = function(ctx, start, end){ ctx.stroke(); } -/** - * draw a circle - * @param {CanvasRenderingContext2D} ctx - * is used for drawing rectangles, text, images and other objects onto the canvas element - * @param {Number} cx - * The x-coordinate of the center of the circle - * @param {Number} xy - * The x-coordinate of the center of the circle - * @param {Number} r - * The radius of the circle - */ -caDrawHelper.prototype.drawCircle = function(ctx, cx, cy, r){ - // draw line - ctx.beginPath(); - ctx.arc(cx, cy, r, 0, 2 * Math.PI); - ctx.stroke(); - ctx.closePath() - -} /** * draw a polygon on a canvas * @param {CanvasRenderingContext2D} ctx @@ -165,8 +171,8 @@ caDrawHelper.prototype.drawPolygon = function(ctx, paths){ // close path and set style path.closePath() if(ctx.isFill ==undefined || ctx.isFill){ - // path.fill(ctx); - path.stroke(ctx); + path.fill(ctx); + //path.stroke(ctx); }else{ path.stroke(ctx); } @@ -221,8 +227,25 @@ caDrawHelper.prototype.draw = function(ctx, image_data){ && !this.isPointInBBox(ctx.viewBoundBoxInData, {x:point[0],y:point[1]})) continue; ctx.fillStyle = (ctx.isFill ==undefined || ctx.isFill)?hexToRgbA(style.color,1):style.color; + console.log(this) polygon.geometry.path = this.circle(ctx, polygon.geometry.coordinates, ctx.radius); - }else if(false){ + } + else if(polygon.geometry.type=='Circle') { + const point = polygon.geometry.coordinates + if(ctx.viewBoundBoxInData + && !this.isPointInBBox(ctx.viewBoundBoxInData, {x:point[0],y:point[1]})) continue; + + ctx.fillStyle = (ctx.isFill ==undefined || ctx.isFill)?hexToRgbA(style.color,1):style.color; + polygon.geometry.path = this.circle(ctx, polygon.geometry.coordinates, polygon.geometry.radius, false); + }else if(polygon.geometry.type=='Ellipse'){ + const point = polygon.geometry.coordinates + if(ctx.viewBoundBoxInData + && !this.isPointInBBox(ctx.viewBoundBoxInData, {x:point[0],y:point[1]})) continue; + + ctx.fillStyle = (ctx.isFill ==undefined || ctx.isFill)?hexToRgbA(style.color,1):style.color; + polygon.geometry.path = this.ellipse(ctx, polygon.geometry.coordinates, polygon.geometry.radius, polygon.geometry.rotation); + } + else if(false){ }else{ // determine drawing or not @@ -328,5 +351,5 @@ var DrawHelper = new caDrawHelper(); //OpenSeadragon.DrawHelper = DrawHelper; +module.exports = caDrawHelper; -module.exports = caDrawHelper; \ No newline at end of file diff --git a/common/util.js b/common/util.js index d0be60d12..9e367ae26 100644 --- a/common/util.js +++ b/common/util.js @@ -411,13 +411,22 @@ function VieweportFeaturesToImageFeatures(viewer, geometries) { this.imgHeight = image.source.dimensions.y; geometries.features = geometries.features.map((feature) => { - if (feature.geometry.type=='Point') { + if (feature.geometry.type=='Point'||feature.geometry.type=='Circle'||feature.geometry.type=='Ellipse') { feature.geometry.coordinates = [ Math.round(feature.geometry.coordinates[0] * imgWidth), Math.round(feature.geometry.coordinates[1] * imgHeight)]; feature.bound.coordinates =[ Math.round(feature.bound.coordinates[0] * imgWidth), Math.round(feature.bound.coordinates[1] * imgHeight)]; + + + + if (feature.geometry.type=='Circle') { + feature.geometry.radius = Math.round(feature.geometry.radius * imgWidth) + } + if (feature.geometry.type=='Ellipse') { + feature.geometry.radius = [Math.round(feature.geometry.radius[0] * imgWidth), Math.round(feature.geometry.radius[1] * imgHeight)]; + } return feature; } feature.geometry.coordinates[0] = feature.geometry.coordinates[0].map( diff --git a/core/Store.js b/core/Store.js index 65ed127eb..30374c993 100644 --- a/core/Store.js +++ b/core/Store.js @@ -36,6 +36,7 @@ function objToParamStr(obj) { class Store { constructor(base, validation, config) { this.base = base || './data/'; + this.validation = validation || {}; this.config = config; } @@ -154,6 +155,57 @@ class Store { mode: 'cors', }); } + findMarks(q) { + const suffix = 'Mark/find'; + const url = this.base + suffix; + var query = {}; + if (q) { + query = q; + } + return fetch(url + '?' + objToParamStr(query), { + credentials: 'include', + mode: 'cors', + }).then(this.errorHandler).then((x) => this.filterBroken(x, 'mark')); + } + + countMarks(q) { + const suffix = 'Mark/count'; + const url = this.base + suffix; + var query = {}; + if (q) { + query = q; + } + return fetch(url + '?' + objToParamStr(query), { + credentials: 'include', + mode: 'cors', + }).then(this.errorHandler).then((x) => this.filterBroken(x, 'mark')); + } + + findSlide(name, specimen, study, location, q) { + let query = {}; + const suffix = 'Slide/find'; + const url = this.base + suffix; + if (q) { + query = q; + } else { + if (name) { + query.name = name; + } + if (study) { + query.study = study; + } + if (specimen) { + query.specimen = specimen; + } + if (location) { + query.location = location; + } + } + return fetch(url + '?' + objToParamStr(query), { + credentials: 'include', + mode: 'cors', + }).then(this.errorHandler); + } /** * find marks matching slide and/or marktype * will search by slide field as exactly given and by the oid slide of that name @@ -945,7 +997,34 @@ class Store { body: JSON.stringify(update), }); } - + /*** + * dicom api start + * + */ + async syncSeries(baseUrl, data = {}) { + // the data structure: + // const {source_url, study, series, modality} = data + + const suffix = 'loader/dicomWeb/importSeries'; + const url = baseUrl + suffix; + // Default options are marked with * + const response = await fetch(url, { + method: "POST", + mode: "cors", + cache: "no-cache", + headers: { + "Content-Type": "application/json", + }, + redirect: "follow", + referrerPolicy: "no-referrer", + body: JSON.stringify(data), + }); + return response.json(); + } + /*** + * dicom api end + * + */ addPresetLabels(labels) { const suffix = 'Presetlabels/add'; const url = this.base + suffix; diff --git a/core/extension/openseadragon-overlays-manage.js b/core/extension/openseadragon-overlays-manage.js index 6116e7d9b..889cb4400 100644 --- a/core/extension/openseadragon-overlays-manage.js +++ b/core/extension/openseadragon-overlays-manage.js @@ -383,6 +383,12 @@ pointPath.closePath(); pointPath.strokeAndFill(ctx); this.editPointPathList.push(pointPath); + } else if (this.editPathData.geometry.type === 'Circle') { + // TODO editor + console.log('drawEditPoints Circle'); + } else if (this.editPathData.geometry.type === 'Ellipse') { + // TODO editor + console.log('drawEditPoints Ellipse'); } else { pathData[0].map((point) => { const pointPath = new Path(); @@ -497,6 +503,12 @@ pointPath.closePath(); pointPath.strokeAndFill(this._edit_tool_ctx_); this.editPointPathList.push(pointPath); + } else if (this.editPathData.geometry.type === 'Circle') { + // TODO editor + console.log('onEditPointMouseMove Circle'); + } else if (this.editPathData.geometry.type === 'Ellipse') { + // TODO editor + console.log('onEditPointMouseMove Ellipse'); } else { // brush this.editPathData.geometry.coordinates[0][this.onEditIndex] = [img_point.x, img_point.y];