Original commit: 132ae3e2fe
This commit is contained in:
Radosław Waśko 2021-03-25 17:03:22 +01:00 committed by GitHub
parent 1205a5dcda
commit 6aeff8f8a4
6 changed files with 602 additions and 31 deletions

View File

@ -11,6 +11,11 @@
will be lost. In this build we added notification in statusbar to signalize
that the connection was lost and IDE must be restarted. In future IDE will try
to automatically reconnect.
- [Database Visualizations][1335]. Visualizations for the Database library have
been added. The Table visualization now automatically executes the underlying
query to display its results in a table. In addition, the SQL Query
visualization allows the user to see the query that is going to be run against
the database.
<br/>![Bug Fixes](/docs/assets/tags/bug_fixes.svg)

View File

@ -20,6 +20,16 @@ pub fn table_visualization() -> visualization::java_script::FallibleDefinition {
visualization::java_script::Definition::new_builtin(source)
}
/// Return a `JavaScript` SQL visualization.
pub fn sql_visualization() -> visualization::java_script::FallibleDefinition {
let loading_scripts = include_str!("java_script/helpers/loading.js");
let scrollable = include_str!("java_script/helpers/scrollable.js");
let source = include_str!("java_script/sql.js");
let source = format!("{}{}{}",loading_scripts,scrollable,source);
visualization::java_script::Definition::new_builtin(source)
}
/// Return a `JavaScript` Scatter plot visualization.
pub fn scatter_plot_visualization() -> visualization::java_script::FallibleDefinition {
let loading_scripts = include_str!("java_script/helpers/loading.js");

View File

@ -127,7 +127,7 @@ class GeoMapVisualization extends Visualization {
columns = df.select ['label', 'latitude', 'longitude'] . columns
serialized = columns.map (c -> ['df_' + c.name, c.to_vector])
Json.from_pairs serialized . to_text
_ -> df . to_json . to_text
_ -> df . to_json . to_text
`)
}

View File

@ -0,0 +1,440 @@
/** SQL visualization. */
// =================
// === Constants ===
// =================
/** The qualified name of the Text type. */
const textType = 'Builtins.Main.Text'
/** The module prefix added for unknown SQL types. */
const customSqlTypePrefix = 'Standard.Database.Data.Sql.Sql_Type.'
/** Specifies opacity of interpolation background color. */
const interpolationBacgroundOpacity = 0.3
/** The CSS styles for the visualization. */
const visualizationStyle = `
<style>
.sql {
font-family: DejaVuSansMonoBook, sans-serif;
font-size: 12px;
margin-left: 7px;
margin-top: 5px;
}
.interpolation {
border-radius: 6px;
padding:1px 2px 1px 2px;
display: inline;
}
.mismatch-parent {
position: relative;
display: inline-flex;
justify-content: center;
}
.mismatch-mouse-area {
display: inline;
position: absolute;
width: 150%;
height: 150%;
align-self: center;
z-index: 0;
}
.mismatch {
z-index: 1;
}
.modulepath {
color: rgba(150, 150, 150, 0.9);
}
.tooltip {
font-family: DejaVuSansMonoBook, sans-serif;
font-size: 12px;
opacity: 0;
transition: opacity 0.2s;
display: inline-block;
white-space: nowrap;
background-color: rgba(249, 249, 249, 1);
box-shadow: 0 0 16px rgba(0, 0, 0, 0.16);
text-align: left;
border-radius: 6px;
padding: 5px;
position: absolute;
z-index: 99999;
pointer-events: none;
}
</style>
`
// =============================
// === Script Initialisation ===
// =============================
loadScript('https://cdnjs.cloudflare.com/ajax/libs/sql-formatter/4.0.2/sql-formatter.min.js')
// ===========================
// === Table visualization ===
// ===========================
/**
* A visualization that pretty-prints generated SQL code and displays type hints related to
* interpolated query parameters.
*/
class SqlVisualization extends Visualization {
// TODO Change the type below once #837 is done:
// 'Standard.Database.Data.Table.Table | Standard.Database.Data.Column.Column'
static inputType = 'Any'
static label = 'SQL Query'
constructor(api) {
super(api)
this.setPreprocessorModule('Standard.Visualization.Sql.Visualization')
this.setPreprocessorCode(`x -> here.prepare_visualization x`)
}
onDataReceived(data) {
this.removeAllChildren()
let parsedData = data
if (typeof data === 'string') {
parsedData = JSON.parse(data)
}
let visHtml = visualizationStyle
if (parsedData.error !== undefined) {
visHtml += parsedData.error
} else {
const params = parsedData.interpolations.map(param =>
renderInterpolationParameter(this.theme, param)
)
let language = 'sql'
if (parsedData.dialect == 'postgresql') {
language = 'postgresql'
}
const formatted = sqlFormatter.format(parsedData.code, {
params: params,
language: language,
})
const codeRepresentation = '<pre class="sql">' + formatted + '</pre>'
visHtml += codeRepresentation
}
const containers = this.createContainers()
const parentContainer = containers.parent
containers.scrollable.innerHTML = visHtml
this.dom.appendChild(parentContainer)
const tooltip = new Tooltip(parentContainer)
const baseMismatches = this.dom.getElementsByClassName('mismatch')
const extendedMismatchAreas = this.dom.getElementsByClassName('mismatch-mouse-area')
setupMouseInteractionForMismatches(tooltip, baseMismatches)
setupMouseInteractionForMismatches(tooltip, extendedMismatchAreas)
}
/**
* Removes all children of this visualization's DOM.
*
* May be used to reset the visualization's content.
*/
removeAllChildren() {
while (this.dom.firstChild) {
this.dom.removeChild(this.dom.lastChild)
}
}
/**
* Creates containers for the visualization.
*/
createContainers() {
const parentContainer = document.createElement('div')
parentContainer.setAttributeNS(null, 'style', 'position: relative;')
const width = this.dom.getAttributeNS(null, 'width')
const height = this.dom.getAttributeNS(null, 'height')
const scrollable = document.createElement('div')
scrollable.setAttributeNS(null, 'id', 'vis-sql-view')
scrollable.setAttributeNS(null, 'class', 'scrollable')
scrollable.setAttributeNS(null, 'viewBox', '0 0 ' + width + ' ' + height)
scrollable.setAttributeNS(null, 'width', '100%')
scrollable.setAttributeNS(null, 'height', '100%')
const viewStyle = `width: ${width - 5}px;
height: ${height - 5}px;
overflow: scroll;
padding:2.5px;`
scrollable.setAttributeNS(null, 'style', viewStyle)
parentContainer.appendChild(scrollable)
return {
parent: parentContainer,
scrollable: scrollable,
}
}
setSize(size) {
this.dom.setAttributeNS(null, 'width', size[0])
this.dom.setAttributeNS(null, 'height', size[1])
}
}
// === Handling Colors ===
/**
* Renders a 4-element array representing a color into a CSS-compatible rgba string.
*/
function convertColorToRgba(color) {
const r = 255 * color.red
const g = 255 * color.green
const b = 255 * color.blue
const a = color.alpha
return 'rgba(' + r + ',' + g + ',' + b + ',' + a + ')'
}
/** Replaces the alpha component of a color (represented as a 4-element array),
* returning a new color.
*/
function replaceAlpha(color, newAlpha) {
return {
red: color.red,
green: color.green,
blue: color.blue,
alpha: newAlpha,
}
}
// === HTML Rendering Helpers ===
/**
* Renders a HTML representation of a message to be displayed in a tooltip,
* which explains a type mismatch.
*/
function renderTypeHintMessage(
receivedTypeName,
expectedTypeName,
receivedTypeColor,
expectedTypeColor
) {
const received = new QualifiedTypeName(receivedTypeName)
const expected = new QualifiedTypeName(expectedTypeName)
let receivedPrefix = ''
if (received.moduleName !== null) {
receivedPrefix = '<span class="modulepath">' + received.moduleName + '.</span>'
}
const receivedStyledSpan = '<span style="color: ' + convertColorToRgba(receivedTypeColor) + '">'
const receivedSuffix = receivedStyledSpan + received.name + '</span>'
let expectedPrefix = ''
if (expected.moduleName !== null) {
expectedPrefix = '<span class="modulepath">' + expected.moduleName + '.</span>'
}
const expectedStyledSpan = '<span style="color: ' + convertColorToRgba(expectedTypeColor) + '">'
const expectedSuffix = expectedStyledSpan + expected.name + '</span>'
let message = 'Received ' + receivedPrefix + receivedSuffix + '<br>'
message += 'Expected ' + expectedPrefix + expectedSuffix + '<br>'
message += 'The database may perform an auto conversion.'
return message
}
/**
* Wraps a qualified type name.
*
* The `moduleName` field is the name of the module the type is from. It may be null if the original
* type name did not contain a module name.
* The `name` field is the simple name of the type itself.
*/
class QualifiedTypeName {
/** Creates a QualifiedTypeName instance from a string representation. */
constructor(typeName) {
let ix = typeName.lastIndexOf('.')
if (ix < 0) {
this.moduleName = null
this.name = typeName
} else {
this.moduleName = typeName.substr(0, ix)
this.name = typeName.substr(ix + 1)
}
}
}
/**
* Renders HTML for displaying an Enso parameter that is interpolated into the SQL code.
*/
function renderInterpolationParameter(theme, param) {
const actualType = param.actual_type
let value = param.value
if (actualType == textType) {
value = "'" + value.replaceAll("'", "''") + "'"
}
const actualTypeColor = theme.getColorForType(actualType)
const fgColor = actualTypeColor
let bgColor = replaceAlpha(fgColor, interpolationBacgroundOpacity)
const expectedEnsoType = param.expected_enso_type
if (actualType == expectedEnsoType) {
return renderRegularInterpolation(value, fgColor, bgColor)
} else {
let expectedType = expectedEnsoType
if (expectedType === null) {
expectedType = customSqlTypePrefix + param.expected_sql_type
}
const expectedTypeColor = theme.getColorForType(expectedType)
const hoverBgColor = expectedTypeColor
bgColor = replaceAlpha(hoverBgColor, interpolationBacgroundOpacity)
const hoverFgColor = theme.getForegroundColorForType(expectedType)
const message = renderTypeHintMessage(
actualType,
expectedType,
actualTypeColor,
expectedTypeColor
)
return renderMismatchedInterpolation(
value,
message,
fgColor,
bgColor,
hoverFgColor,
hoverBgColor
)
}
}
/**
* A helper that renders the HTML representation of a regular SQL interpolation.
*/
function renderRegularInterpolation(value, fgColor, bgColor) {
let html =
'<div class="interpolation" style="color:' +
convertColorToRgba(fgColor) +
';background-color:' +
convertColorToRgba(bgColor) +
';">'
html += value
html += '</div>'
return html
}
/**
* A helper that renders the HTML representation of a type-mismatched SQL interpolation.
*
* This only prepares the HTML code, to setup the interactions, `setupMouseInteractionForMismatches`
* must be called after these HTML elements are added to the DOM.
*/
function renderMismatchedInterpolation(
value,
message,
fgColor,
bgColor,
hoverFgColor,
hoverBgColor
) {
let html = '<div class="mismatch-parent">'
html += '<div class="mismatch-mouse-area"></div>'
html += '<div class="interpolation mismatch"'
let style = 'color:' + convertColorToRgba(fgColor) + ';'
style += 'background-color:' + convertColorToRgba(bgColor) + ';'
html += ' style="' + style + '"'
html += ' data-fgColor="' + convertColorToRgba(fgColor) + '"'
html += ' data-bgColor="' + convertColorToRgba(bgColor) + '"'
html += ' data-fgColorHover="' + convertColorToRgba(hoverFgColor) + '"'
html += ' data-bgColorHover="' + convertColorToRgba(hoverBgColor) + '"'
html += ' data-message="' + encodeURIComponent(message) + '"'
html += '>'
html += value
html += '</div>'
html += '</div>'
return html
}
// === Tooltip ===
/**
* A hint tooltip that can be displayed above elements.
*/
class Tooltip {
constructor(container) {
this.tooltip = document.createElement('div')
this.tooltip.setAttributeNS(null, 'class', 'tooltip')
container.appendChild(this.tooltip)
this.tooltipOwner = null
}
/**
* Hides the tooltip.
*
* The actor parameter specifies who is initiating the hiding.
* If this method is called but the tooltip has got a new owner in the meantime, the call is
* ignored.
*/
hide(actor) {
if (this.tooltipOwner === null || this.tooltipOwner == actor) {
this.tooltipOwner = null
this.tooltip.style.opacity = 0
}
}
/**
* Shows the tooltip above the element represented by `actor`.
*
* Tooltip content is specified by the `message` which can include arbitrary HTML.
*/
show(actor, message) {
this.tooltipOwner = actor
this.tooltip.innerHTML = message
this.tooltip.style.opacity = 1
const interpolantContainer = actor.parentElement
const codeContainer = interpolantContainer.parentElement
const scrollElement = codeContainer.parentElement
const scrollOffsetX = scrollElement.scrollLeft
const scrollOffsetY = scrollElement.scrollTop + scrollElement.offsetHeight
const interpolantOffsetX = interpolantContainer.offsetLeft
const interpolantOffsetY = interpolantContainer.offsetTop
const centeringOffset = (interpolantContainer.offsetWidth - this.tooltip.offsetWidth) / 2
const belowPadding = 3
const belowOffset = interpolantContainer.offsetHeight + belowPadding
const x = interpolantOffsetX - scrollOffsetX + centeringOffset
const y = interpolantOffsetY - scrollOffsetY + belowOffset
this.tooltip.style.transform = 'translate(' + x + 'px, ' + y + 'px)'
}
}
/**
* Sets up mouse events for the interpolated parameters that have a type mismatch.
*/
function setupMouseInteractionForMismatches(tooltip, elements) {
function interpolationMouseEnter(event) {
const target = this.parentElement.getElementsByClassName('mismatch')[0]
const fg = target.getAttribute('data-fgColorHover')
const bg = target.getAttribute('data-bgColorHover')
const message = decodeURIComponent(target.getAttribute('data-message'))
tooltip.show(target, message)
target.style.color = fg
target.style.backgroundColor = bg
}
function interpolationMouseLeave(event) {
const target = this.parentElement.getElementsByClassName('mismatch')[0]
const fg = target.getAttribute('data-fgColor')
const bg = target.getAttribute('data-bgColor')
target.style.color = fg
target.style.backgroundColor = bg
tooltip.hide(target)
}
for (let i = 0; i < elements.length; ++i) {
elements[i].addEventListener('mouseenter', interpolationMouseEnter)
elements[i].addEventListener('mouseleave', interpolationMouseLeave)
}
}
return SqlVisualization

View File

@ -16,16 +16,8 @@ class TableVisualization extends Visualization {
constructor(data) {
super(data)
this.setPreprocessorModule('Standard.Table')
this.setPreprocessorCode(`
x -> case x of
Table.Table _ ->
header = ["header", x.columns.map .name]
data = ["data", x.columns.map .to_vector . map (x -> x.take_start 2000) ]
pairs = [header,data]
Json.from_pairs pairs . to_text
_ -> x . to_json . to_text
`)
this.setPreprocessorModule('Standard.Visualization.Table.Visualization')
this.setPreprocessorCode(`x -> here.prepare_visualization x 1000`)
}
onDataReceived(data) {
@ -132,9 +124,13 @@ class TableVisualization extends Visualization {
function genGenericTable(data, level) {
let result = ''
data.forEach((point, ix) => {
result += '<tr><th>' + ix + '</th>' + toTableCell(point, level) + '</tr>'
})
if (Array.isArray(data)) {
data.forEach((point, ix) => {
result += '<tr><th>' + ix + '</th>' + toTableCell(point, level) + '</tr>'
})
} else {
result += '<tr>' + toTableCell(data, level) + '</tr>'
}
return tableOf(result, level)
}
@ -174,52 +170,137 @@ class TableVisualization extends Visualization {
}
}
function genDataframe(parsedData) {
let result = ''
function addHeader(content) {
result += '<th>' + content + '</th>'
}
function addCell(content) {
result += '<td class="plaintext">' + content + '</td>'
}
result += '<tr>'
parsedData.indices_header.forEach(addHeader)
parsedData.header.forEach(addHeader)
result += '</tr>'
let rows = 0
if (parsedData.data.length > 0) {
rows = parsedData.data[0].length
} else if (parsedData.indices.length > 0) {
rows = parsedData.indices[0].length
}
for (let i = 0; i < rows; ++i) {
result += '<tr>'
parsedData.indices.forEach(ix => addHeader(ix[i]))
parsedData.data.forEach(col => addCell(col[i]))
result += '</tr>'
}
return tableOf(result, 0)
}
while (this.dom.firstChild) {
this.dom.removeChild(this.dom.lastChild)
}
const style_dark = `
<style>
table {
table, .hiddenrows {
font-family: DejaVuSansMonoBook, sans-serif;
font-size: 12px;
}
table {
border-spacing: 1px;
padding: 1px;
}
table > tbody > tr:first-child > th:first-child,
table > tbody > tr:first-child > td:first-child {
border-top-left-radius: 9px;
}
table > tbody > tr:first-child > th:last-child,
table > tbody > tr:first-child > td:last-child {
border-top-right-radius: 9px;
}
table > tbody > tr:last-child > th:first-child,
table > tbody > tr:last-child > td:first-child {
border-bottom-left-radius: 9px;
}
table > tbody > tr:last-child > th:last-child,
table > tbody > tr:last-child > td:last-child {
border-bottom-right-radius: 9px;
}
td {
color: rgba(255, 255, 255, 0.9);
padding: 0;
}
td.plaintext,
th {
padding: 5px;
}
th,
td {
border: 1px solid transparent;
background-clip: padding-box;
}
th {
th, .hiddenrows {
color: rgba(255, 255, 255, 0.7);
font-weight: 400;
}
td,
th {
td {
background-color: rgba(255, 255, 255, 0.03);
}
th {
background-color: rgba(255, 255, 200, 0.1);
}
.hiddenrows {
margin-left: 5px;
margin-top: 5px;
}
</style>
`
const style_light = `
<style>
table {
table, .hiddenrows {
font-family: DejaVuSansMonoBook, sans-serif;
font-size: 12px;
}
table {
border-spacing: 1px;
padding: 1px;
}
table > tbody > tr:first-child > th:first-child,
table > tbody > tr:first-child > td:first-child {
border-top-left-radius: 9px;
}
table > tbody > tr:first-child > th:last-child,
table > tbody > tr:first-child > td:last-child {
border-top-right-radius: 9px;
}
table > tbody > tr:last-child > th:first-child,
table > tbody > tr:last-child > td:first-child {
border-bottom-left-radius: 9px;
}
table > tbody > tr:last-child > th:last-child,
table > tbody > tr:last-child > td:last-child {
border-bottom-right-radius: 9px;
}
td {
color: rgba(0, 0, 0, 0.7);
padding: 0;
@ -236,15 +317,23 @@ class TableVisualization extends Visualization {
background-clip: padding-box;
}
th {
th, .hiddenrows {
color: rgba(0, 0, 0, 0.9);
font-weight: 400;
}
td,
th {
td {
background-color: rgba(0, 0, 0, 0.025);
}
th {
background-color: rgba(30, 30, 20, 0.1);
}
.hiddenrows {
margin-left: 5px;
margin-top: 5px;
}
</style>`
const width = this.dom.getAttributeNS(null, 'width')
@ -255,8 +344,8 @@ class TableVisualization extends Visualization {
tabElem.setAttributeNS(null, 'viewBox', '0 0 ' + width + ' ' + height)
tabElem.setAttributeNS(null, 'width', '100%')
tabElem.setAttributeNS(null, 'height', '100%')
const tblViewStyle = `width: ${width - 10}px;
height: ${height - 10}px;
const tblViewStyle = `width: ${width - 5}px;
height: ${height - 5}px;
overflow: scroll;
padding:2.5px;`
tabElem.setAttributeNS(null, 'style', tblViewStyle)
@ -271,8 +360,34 @@ class TableVisualization extends Visualization {
if (document.getElementById('root').classList.contains('dark-theme')) {
style = style_dark
}
const table = genTable(parsedData.data || parsedData, 0, parsedData.header)
tabElem.innerHTML = style + table
if (parsedData.error !== undefined) {
tabElem.innerHTML = 'Error: ' + parsedData.error
} else if (parsedData.json !== undefined) {
const table = genTable(parsedData.json, 0, undefined)
tabElem.innerHTML = style + table
} else {
const table = genDataframe(parsedData)
let suffix = ''
const allRowsCount = parsedData.all_rows_count
if (allRowsCount !== undefined) {
const includedRowsCount = parsedData.data.length > 0 ? parsedData.data[0].length : 0
const hiddenCount = allRowsCount - includedRowsCount
if (hiddenCount > 0) {
let rows = 'rows'
if (hiddenCount == 1) {
rows = 'row'
}
suffix =
'<span class="hiddenrows">&#8230; and ' +
hiddenCount +
' more ' +
rows +
'.</span>'
}
}
tabElem.innerHTML = style + table + suffix
}
}
setSize(size) {

View File

@ -86,6 +86,7 @@ impl Registry {
self.try_add_java_script(builtin::visualization::java_script::scatter_plot_visualization());
self.try_add_java_script(builtin::visualization::java_script::histogram_visualization());
self.try_add_java_script(builtin::visualization::java_script::table_visualization());
self.try_add_java_script(builtin::visualization::java_script::sql_visualization());
self.try_add_java_script(builtin::visualization::java_script::geo_map_visualization());
}