Skip to content

Commit 3c413da

Browse files
committed
new static table renderer including fold-out tables in FrameColumns and flattened ColGroups for static tables in ipynb files
1 parent 47723fc commit 3c413da

File tree

4 files changed

+366
-86
lines changed
  • core
    • generated-sources/src/main/kotlin/org/jetbrains/kotlinx/dataframe
    • src
      • main/kotlin/org/jetbrains/kotlinx/dataframe/io
      • test/kotlin/org/jetbrains/kotlinx/dataframe/rendering

4 files changed

+366
-86
lines changed

core/generated-sources/src/main/kotlin/org/jetbrains/kotlinx/dataframe/impl/columns/Utils.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ internal fun <T> ColumnWithPath<T>.changePath(path: ColumnPath): ColumnWithPath<
5757

5858
internal fun <T> BaseColumn<T>.addParentPath(path: ColumnPath) = addPath(path + name)
5959

60-
internal fun <T> BaseColumn<T>.addPath(): ColumnWithPath<T> = addPath(pathOf(name))
60+
/* TODO internal */ public fun <T> BaseColumn<T>.addPath(): ColumnWithPath<T> = addPath(pathOf(name))
6161

6262
internal fun ColumnPath.depth() = size - 1
6363

core/generated-sources/src/main/kotlin/org/jetbrains/kotlinx/dataframe/io/html.kt

Lines changed: 167 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,12 @@ import org.jetbrains.kotlinx.dataframe.AnyCol
55
import org.jetbrains.kotlinx.dataframe.AnyFrame
66
import org.jetbrains.kotlinx.dataframe.AnyRow
77
import org.jetbrains.kotlinx.dataframe.DataFrame
8-
import org.jetbrains.kotlinx.dataframe.api.FormattingDSL
9-
import org.jetbrains.kotlinx.dataframe.api.RowColFormatter
10-
import org.jetbrains.kotlinx.dataframe.api.asNumbers
11-
import org.jetbrains.kotlinx.dataframe.api.isEmpty
12-
import org.jetbrains.kotlinx.dataframe.api.isNumber
13-
import org.jetbrains.kotlinx.dataframe.api.isSubtypeOf
14-
import org.jetbrains.kotlinx.dataframe.api.rows
8+
import org.jetbrains.kotlinx.dataframe.api.*
9+
import org.jetbrains.kotlinx.dataframe.columns.BaseColumn
1510
import org.jetbrains.kotlinx.dataframe.columns.ColumnGroup
11+
import org.jetbrains.kotlinx.dataframe.columns.ColumnWithPath
1612
import org.jetbrains.kotlinx.dataframe.impl.DataFrameSize
13+
import org.jetbrains.kotlinx.dataframe.impl.columns.addPath
1714
import org.jetbrains.kotlinx.dataframe.impl.renderType
1815
import org.jetbrains.kotlinx.dataframe.impl.scale
1916
import org.jetbrains.kotlinx.dataframe.impl.truncate
@@ -175,13 +172,21 @@ internal fun AnyFrame.toHtmlData(
175172
return DataFrameHtmlData("", body, script)
176173
}
177174

178-
internal fun AnyFrame.toStaticHtml(
175+
/**
176+
* Renders [this] [DataFrame] as static HTML (meaning no JS is used).
177+
* CSS rendering is enabled by default but can be turned off using [includeCss]
178+
*/
179+
public fun AnyFrame.toStaticHtml(
179180
configuration: DisplayConfiguration = DisplayConfiguration.DEFAULT,
180-
cellRenderer: CellRenderer,
181+
cellRenderer: CellRenderer = DefaultCellRenderer,
182+
includeCss: Boolean = true,
181183
): DataFrameHtmlData {
182184
val df = this
183185
val id = "static_df_${nextTableId()}"
184-
val columnsToRender = columns()
186+
val flattenedCols = getColumnsWithPaths { cols { !it.isColumnGroup() }.recursively() }
187+
val colGrid = getColumnsHeaderGrid()
188+
val borders = colGrid.last().map { it.borders }
189+
val nestedRowsLimit = configuration.nestedRowsLimit
185190

186191
fun StringBuilder.emitTag(tag: String, attributes: String = "", tagContents: StringBuilder.() -> Unit) {
187192
append("<")
@@ -200,22 +205,57 @@ internal fun AnyFrame.toStaticHtml(
200205
}
201206

202207
fun StringBuilder.emitHeader() = emitTag("thead") {
203-
emitTag("tr") {
204-
columnsToRender.forEach { col ->
205-
emitTag("th") {
206-
append(col.name())
208+
for (row in colGrid) {
209+
emitTag("tr") {
210+
for ((j, col) in row.withIndex()) {
211+
var borders = col.borders
212+
if (row.getOrNull(j + 1)?.borders?.contains(Border.LEFT) == true) {
213+
// because left does not work unless at first column
214+
borders += Border.RIGHT
215+
}
216+
emitTag("th", borders.toClass()) {
217+
append(col.columnWithPath?.name ?: "")
218+
}
207219
}
208220
}
209221
}
210222
}
211223

212-
fun StringBuilder.emitCell(cellValue: Any?) = emitTag("td") {
213-
append(cellRenderer.content(cellValue, configuration).truncatedContent)
224+
fun StringBuilder.emitCell(cellValue: Any?, borders: Set<Border>): Unit = emitTag("td", borders.toClass()) {
225+
when (cellValue) {
226+
is AnyFrame ->
227+
emitTag("details") {
228+
emitTag("summary") {
229+
append("DataFrame ")
230+
append(cellRenderer.content(cellValue, configuration).truncatedContent)
231+
}
232+
append(
233+
cellValue.take(nestedRowsLimit ?: Int.MAX_VALUE)
234+
.toStaticHtml(configuration, cellRenderer, includeCss = false)
235+
.toString()
236+
)
237+
val size = cellValue.rowsCount()
238+
if (size > nestedRowsLimit ?: Int.MAX_VALUE) {
239+
emitTag("p") {
240+
append("... showing only top $nestedRowsLimit of $size rows")
241+
}
242+
}
243+
}
244+
245+
else ->
246+
append(cellRenderer.content(cellValue, configuration).truncatedContent)
247+
}
214248
}
215249

216250
fun StringBuilder.emitRow(row: AnyRow) = emitTag("tr") {
217-
columnsToRender.forEach { col ->
218-
emitCell(row[col.path()])
251+
for ((i, col) in flattenedCols.withIndex()) {
252+
var border = borders[i]
253+
if (borders.getOrNull(i + 1)?.contains(Border.LEFT) == true) {
254+
// because left does not work unless at first column
255+
border += Border.RIGHT
256+
}
257+
val cell = row[col.path()]
258+
emitCell(cell, border)
219259
}
220260
}
221261

@@ -231,12 +271,79 @@ internal fun AnyFrame.toStaticHtml(
231271
emitBody()
232272
}
233273

234-
return DataFrameHtmlData(
235-
body = buildString { emitTable() },
236-
script = """
237-
document.getElementById("$id").style.display = "none";
238-
""".trimIndent()
239-
)
274+
return DataFrameHtmlData(body = buildString { emitTable() }) +
275+
DataFrameHtmlData.tableDefinitions(includeJs = false, includeCss = includeCss)
276+
}
277+
278+
private enum class Border(val className: String) {
279+
// NOTE: these don't render unless at leftmost; add rightborder to the previous cell.
280+
LEFT("leftBorder"),
281+
RIGHT("rightBorder"),
282+
BOTTOM("bottomBorder");
283+
}
284+
285+
private fun Set<Border>.toClass(): String = "class=\"${joinToString(" ") { it.className }}\""
286+
287+
private data class ColumnWithPathWithBorder<T>(
288+
val columnWithPath: ColumnWithPath<T>? = null,
289+
val borders: Set<Border> = emptySet(),
290+
)
291+
292+
/** Returns the depth of the most-nested column in this group, starting at 0 */
293+
private fun ColumnGroup<*>.maxDepth(): Int = getColumnsWithPaths { all().recursively() }.maxOf { it.depth() }
294+
295+
/** Returns the max number of columns needed to display this column flattened */
296+
private fun BaseColumn<*>.maxWidth(): Int =
297+
if (this is ColumnGroup<*>) columns().sumOf { it.maxWidth() }.coerceAtLeast(1)
298+
else 1
299+
300+
/**
301+
* Given a [DataFrame], this function returns a depth-first "matrix" containing all columns
302+
* layed out in such a way that they can be used to render the header of a table. The
303+
* [ColumnWithPathWithBorder.columnWithPath] is `null` when nothing should be rendered in that cell.
304+
* Borders are included too, also for `null` cells.
305+
*
306+
* For example:
307+
* ```
308+
* `colGroup` ` |` `|colD` `colC`
309+
* `colA ` `colB|` `| ` ` `
310+
* ---- ---- ---- ----
311+
* ```
312+
*/
313+
private fun AnyFrame.getColumnsHeaderGrid(): List<List<ColumnWithPathWithBorder<*>>> {
314+
val colGroup = asColumnGroup("")
315+
val maxDepth = colGroup.maxDepth()
316+
val maxWidth = colGroup.maxWidth()
317+
val map =
318+
MutableList(maxDepth + 1) { MutableList(maxWidth) { ColumnWithPathWithBorder<Any?>() } }
319+
320+
fun ColumnWithPath<*>.addChildren(depth: Int = 0, breadth: Int = 0) {
321+
var breadth = breadth
322+
val children = children()
323+
val lastIndex = children.lastIndex
324+
for ((i, child) in children().withIndex()) {
325+
map[depth][breadth] = map[depth][breadth].copy(columnWithPath = child)
326+
327+
// draw colGroup borders unless at start/end of table
328+
val borders = mutableSetOf<Border>()
329+
if (i == 0 && breadth != 0) borders += Border.LEFT
330+
if (i == lastIndex && breadth != maxWidth - 1) borders += Border.RIGHT
331+
if (depth == maxDepth) borders += Border.BOTTOM
332+
333+
if (borders.isNotEmpty()) {
334+
for (j in (depth - 1).coerceAtLeast(0)..maxDepth) {
335+
map[j][breadth] = map[j][breadth].let { it.copy(borders = it.borders + borders) }
336+
}
337+
}
338+
339+
if (child is ColumnGroup<*>) {
340+
child.addChildren(depth + 1, breadth)
341+
}
342+
breadth += child.maxWidth()
343+
}
344+
}
345+
colGroup.addPath().addChildren()
346+
return map
240347
}
241348

242349
internal fun DataFrameHtmlData.print() = println(this)
@@ -305,28 +412,45 @@ public data class DataFrameHtmlData(
305412
@Language("html", prefix = "<body>", suffix = "</body>") val body: String = "",
306413
@Language("js") val script: String = ""
307414
) {
308-
@Language("html")
309-
override fun toString(): String = """
310-
<html>
311-
<head>
312-
<style type="text/css">
313-
$style
314-
</style>
315-
</head>
316-
<body>
317-
$body
318-
</body>
319-
<script>
320-
$script
321-
</script>
322-
</html>
323-
""".trimIndent()
415+
override fun toString(): String = buildString {
416+
appendLine("<html>")
417+
if (style.isNotBlank()) {
418+
appendLine("<head>")
419+
appendLine("<style type=\"text/css\">")
420+
appendLine(style)
421+
appendLine("</style>")
422+
appendLine("</head>")
423+
}
424+
if (body.isNotBlank()) {
425+
appendLine("<body>")
426+
appendLine(body)
427+
appendLine("</body>")
428+
}
429+
if (script.isNotBlank()) {
430+
appendLine("<script>")
431+
appendLine(script)
432+
appendLine("</script>")
433+
}
434+
appendLine("</html>")
435+
}
324436

325437
public operator fun plus(other: DataFrameHtmlData): DataFrameHtmlData =
326438
DataFrameHtmlData(
327-
style + "\n" + other.style,
328-
body + "\n" + other.body,
329-
script + "\n" + other.script,
439+
style = when {
440+
style.isBlank() -> other.style
441+
other.style.isBlank() -> style
442+
else -> style + "\n" + other.style
443+
},
444+
body = when {
445+
body.isBlank() -> other.body
446+
other.body.isBlank() -> body
447+
else -> body + "\n" + other.body
448+
},
449+
script = when {
450+
script.isBlank() -> other.script
451+
other.script.isBlank() -> script
452+
else -> script + "\n" + other.script
453+
},
330454
)
331455

332456
public fun writeHTML(destination: File) {

0 commit comments

Comments
 (0)