Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add classification component #612

Draft
wants to merge 13 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,7 @@ ENV/

typings/

docs/source/generated/
docs/source/reference/
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was this on purpose?

dist/

.DS_Store
.Rproj.user
55 changes: 55 additions & 0 deletions examples/ml/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
from shiny import App, Inputs, Outputs, Session, render, ui

app_ui = ui.page_fluid(
ui.layout_sidebar(
ui.panel_sidebar(
ui.input_slider("lion", "Lion value:", min=0, max=100, value=60, step=1),
),
ui.panel_main(
ui.h3("Dynamic output, display_winner=True"),
ui.output_ui("label1"),
ui.h3(
"Static output, display_winner=True, max_items=2",
style="margin-top: 3rem;",
),
ui.ml.classification_label(
{
"Tigers": 32,
"Lions": 60,
"Bears": 15,
},
display_winner=True,
max_items=2,
),
ui.h3("Static output, sort=False"),
ui.ml.classification_label(
{
"Tigers": 32,
"Lions": 60,
"Bears": 15,
},
max_items=3,
sort=False,
),
),
)
)


def server(input: Inputs, output: Outputs, session: Session):
@output
@render.ui
def label1():
return (
ui.ml.classification_label(
value={
"Tigers": 32,
"Lions": input.lion(),
"Bears": 15,
},
display_winner=True,
),
)


app = App(app_ui, server)
33 changes: 32 additions & 1 deletion js/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,39 @@ module.exports = {
},
plugins: ["react", "@typescript-eslint"],
rules: {
"@typescript-eslint/no-unused-vars": "off",
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["warn", { args: "none" }],
"@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-inferrable-types": "off",
"@typescript-eslint/naming-convention": [
"error",
{
selector: "default",
format: ["camelCase"],
leadingUnderscore: "allow",
trailingUnderscore: "allow",
},
{
selector: "variable",
format: ["camelCase", "UPPER_CASE"],
leadingUnderscore: "allow",
trailingUnderscore: "allow",
},
{ selector: "typeLike", format: ["PascalCase"] },
{
selector: ["objectLiteralProperty", "typeProperty"],
format: null,
},
{
selector: "function",
// Allow PascalCase for React components.
format: ["camelCase", "PascalCase"],
leadingUnderscore: "allow",
trailingUnderscore: "allow",
},
{ selector: "enumMember", format: ["PascalCase"] },
],
},
settings: {
react: {
Expand Down
77 changes: 55 additions & 22 deletions js/build.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,60 @@
import { BuildOptions, build } from "esbuild";
import esbuild from "esbuild";
import { sassPlugin } from "esbuild-sass-plugin";
import * as fs from "node:fs";

async function bundle() {
try {
const options: BuildOptions = {
entryPoints: { dataframe: "dataframe/index.tsx" },
format: "esm",
bundle: true,
outdir: "../shiny/www/shared/dataframe",
minify: true,
sourcemap: true,
plugins: [sassPlugin({ type: "css-text", sourceMap: false })],
metafile: true,
};
// Set a boolean value of watch to true if the flag --watch is provided when the file is run from the command line.
// E.g. tsx scripts/build.ts --watch
const watch: boolean = process.argv.includes("--watch");
const minify: boolean = process.argv.includes("--minify");
const metafile: boolean = process.argv.includes("--metafile");

const result = await build(options);
console.log("Build completed successfully!");
// console.log("Output:", result);
fs.writeFileSync("esbuild-metadata.json", JSON.stringify(result.metafile));
} catch (error) {
console.error("Build failed:", error);
}
}
const rebuildLoggerPlugin = {
name: "rebuild-logger",
setup(build: esbuild.PluginBuild) {
build.onStart(() => {
console.log(`[${new Date().toISOString()}] Rebuilding JS files...`);
});
},
};

bundle();
const metafilePlugin = {
name: "metafile",
setup(build: esbuild.PluginBuild) {
build.onEnd((result) => {
if (metafile) {
fs.writeFileSync(
"esbuild-metadata.json",
JSON.stringify(result.metafile)
);
}
});
},
};

esbuild
.context({
entryPoints: {
"dataframe/dataframe": "dataframe/index.tsx",
"ml/ml": "ml/index.ts",
},
format: "esm",
bundle: true,
outdir: "../shiny/www/shared",
minify: minify,
sourcemap: true,
metafile: true,
plugins: [
sassPlugin({ type: "css-text", sourceMap: false }),
rebuildLoggerPlugin,
metafilePlugin,
],
})
.then((context) => {
if (watch) {
context.watch();
} else {
context.rebuild();
context.dispose();
}
})
.catch(() => process.exit(1));
17 changes: 4 additions & 13 deletions js/dataframe/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,33 +3,25 @@ import css from "./styles.scss";
import {
Column,
ColumnDef,
Row,
RowModel,
Table,
TableOptions,
flexRender,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import {
VirtualItem,
Virtualizer,
useVirtualizer,
} from "@tanstack/react-virtual";
import { Virtualizer, useVirtualizer } from "@tanstack/react-virtual";
import React, {
FC,
StrictMode,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { Root, createRoot } from "react-dom/client";
import { ErrorsMessageValue } from "rstudio-shiny/srcts/types/src/shiny/shinyapp";
import { findFirstItemInView, getStyle } from "./dom-utils";
import { getStyle } from "./dom-utils";
import { SelectionMode, useSelection } from "./selection";
import { SortArrow } from "./sort-arrows";
import { useTabindexGroup } from "./tabindex-group";
Expand Down Expand Up @@ -70,7 +62,7 @@ interface ShinyDataGridProps<TIndex> {
bgcolor?: string;
}

const ShinyDataGrid: FC<ShinyDataGridProps<unknown>> = (props) => {
function ShinyDataGrid(props: ShinyDataGridProps<unknown>): React.JSX.Element {
const { id, data, bgcolor } = props;
const { columns, index, data: rowData } = data;
const { width, height } = data.options;
Expand Down Expand Up @@ -153,7 +145,6 @@ const ShinyDataGrid: FC<ShinyDataGridProps<unknown>> = (props) => {

const rowSelectionMode =
data.options["row_selection_mode"] ?? SelectionMode.MultiNative;
const canSelect = rowSelectionMode !== SelectionMode.None;
const canMultiSelect =
rowSelectionMode === SelectionMode.MultiNative ||
rowSelectionMode === SelectionMode.Multiple;
Expand Down Expand Up @@ -325,7 +316,7 @@ const ShinyDataGrid: FC<ShinyDataGridProps<unknown>> = (props) => {
{summary}
</>
);
};
}

function findKeysBetween<TData>(
rowModel: RowModel<TData>,
Expand Down
8 changes: 5 additions & 3 deletions js/dataframe/sort-arrows.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { SortDirection } from "@tanstack/react-table";
import React, { FC } from "react";
import React from "react";

const sortCommonProps = {
className: "sort-arrow",
Expand Down Expand Up @@ -39,7 +39,9 @@ interface SortArrowProps {
direction: SortDirection | false;
}

export const SortArrow: FC<SortArrowProps> = ({ direction }) => {
export function SortArrow({
direction,
}: SortArrowProps): React.JSX.Element | null {
if (!direction) {
return null;
}
Expand All @@ -50,7 +52,7 @@ export const SortArrow: FC<SortArrowProps> = ({ direction }) => {
return sortArrowDown;
}
throw new Error(`Unexpected sort direction: '${direction}'`);
};
}

//const sortArrowUp = <span className="sort-arrow sort-arrow-up"> ▲</span>;
//const sortArrowDown = <span className="sort-arrow sort-arrow-down"> ▼</span>;
120 changes: 120 additions & 0 deletions js/ml/classification-label.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { LitElement, css, html } from "lit";
import { property } from "lit/decorators.js";

export class ShinyClassificationLabel extends LitElement {
static styles = css`
:host {
display: block;
padding-bottom: 2rem;
}

.wrapper {
border: 1px solid #ccc;
padding: 12px;
border-radius: 5px;
}

.item {
margin-top: 20px;
margin-bottom: 20px;
}

.winner {
display: flex;
justify-content: center;
font-weight: bold;
font-size: 1.75em;
}

.bar {
height: 5px;
margin-top: 5px;
margin-bottom: 5px;
background-color: #4890e3;
border-radius: 2px;
}

.label {
display: flex;
flex-direction: row;
align-items: baseline;
}

.dashed-line {
flex: 1 1 0%;
border-bottom: 1px dashed #888;
margin-left: 0.5em;
margin-right: 0.5em;
}
Comment on lines +6 to +48
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Were you planning to make these rules use CSS variables? And/or default to existing CSS variables?

`;

@property({ type: Object }) value: Record<string, number> = {};
@property({ type: Number }) sort: number = 1;
@property({ type: Number, attribute: "display-winner" })
displayWinner: number = 0;
@property({ type: Number, attribute: "max-items" })
maxItems: number | null = null;
@property({ type: String, attribute: "suffix" }) suffix: string = "%";

render() {
let entries = Object.entries(this.value);

if (this.maxItems !== null) {
entries = truncateEntries(entries, this.maxItems);
}

if (this.sort) {
entries.sort((a, b) => b[1] - a[1]);
}

let winnerHTML = null;
if (this.displayWinner) {
// Entries might not be sorted, so we need to loop through again.
const currentWinner = { name: "", value: -Infinity };
entries.forEach(([k, v]) => {
if (v > currentWinner.value) {
currentWinner.name = k;
currentWinner.value = v;
}
});
winnerHTML = html`<div class="winner">${currentWinner.name}</div>`;
}

const valuesHtml = entries.map(([k, v]) => {
return html`<div class="item">
<div class="bar" style="width: ${v}${this.suffix};"></div>
<div class="label">
<div>${k}</div>
<div class="dashed-line"></div>
<div>${v}%</div>
</div>
</div>`;
});

return html`<div class="wrapper">${winnerHTML} ${valuesHtml}</div> `;
}
}

customElements.define("shiny-classification-label", ShinyClassificationLabel);

function truncateEntries(entries: [string, number][], maxItems: number | null) {
// Just the numeric values
const values = entries.map(([_, v]) => v);
values.sort().reverse();

const cutoffValue = values[maxItems - 1];

const newEntries = [];
for (const entry of entries) {
if (entry[1] >= cutoffValue) {
newEntries.push(entry);
// In case there are multiple items that match cutoffValue, we need to make sure
// we don't add more entries than were asked for.
if (newEntries.length === maxItems) {
break;
}
}
}

return newEntries;
}
Loading
Loading