Compare commits

...

2 Commits

Author SHA1 Message Date
1e3dc2938b Merge pull request 'adding data browser' (#2) from dev into main
Reviewed-on: #2
2024-09-08 22:14:46 -04:00
224e6388c1 adding data browser 2024-09-08 22:11:51 -04:00
48 changed files with 1037 additions and 75 deletions

View File

@@ -25,7 +25,8 @@
"inlineStyleLanguage": "scss", "inlineStyleLanguage": "scss",
"assets": ["src/favicon.ico", "src/assets"], "assets": ["src/favicon.ico", "src/assets"],
"styles": ["src/styles.scss"], "styles": ["src/styles.scss"],
"scripts": [] "scripts": [],
"allowedCommonJsDependencies": ["papaparse"]
}, },
"configurations": { "configurations": {
"production": { "production": {

49
package-lock.json generated
View File

@@ -16,6 +16,8 @@
"@angular/platform-browser": "^17.3.0", "@angular/platform-browser": "^17.3.0",
"@angular/platform-browser-dynamic": "^17.3.0", "@angular/platform-browser-dynamic": "^17.3.0",
"@angular/router": "^17.3.0", "@angular/router": "^17.3.0",
"@fortawesome/angular-fontawesome": "^0.14.1",
"@fortawesome/free-solid-svg-icons": "^6.6.0",
"chart.js": "^4.4.3", "chart.js": "^4.4.3",
"papaparse": "^5.4.1", "papaparse": "^5.4.1",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
@@ -2683,6 +2685,53 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/@fortawesome/angular-fontawesome": {
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/@fortawesome/angular-fontawesome/-/angular-fontawesome-0.14.1.tgz",
"integrity": "sha512-Yb5HLiEOAxjSLEcaOM51CKIrzdfvoDafXVJERm9vufxfZkVZPZJgrZRgqwLVpejgq4/Ez6TqHZ6SqmJwdtRF6g==",
"license": "MIT",
"dependencies": {
"tslib": "^2.6.2"
},
"peerDependencies": {
"@angular/core": "^17.0.0",
"@fortawesome/fontawesome-svg-core": "~1.2.27 || ~1.3.0-beta2 || ^6.1.0"
}
},
"node_modules/@fortawesome/fontawesome-common-types": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.6.0.tgz",
"integrity": "sha512-xyX0X9mc0kyz9plIyryrRbl7ngsA9jz77mCZJsUkLl+ZKs0KWObgaEBoSgQiYWAsSmjz/yjl0F++Got0Mdp4Rw==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/fontawesome-svg-core": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.6.0.tgz",
"integrity": "sha512-KHwPkCk6oRT4HADE7smhfsKudt9N/9lm6EJ5BVg0tD1yPA5hht837fB87F8pn15D8JfTqQOjhKTktwmLMiD7Kg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.6.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-solid-svg-icons": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.6.0.tgz",
"integrity": "sha512-IYv/2skhEDFc2WGUcqvFJkeK39Q+HyPf5GHUrT/l2pKbtgEIv1al1TKd6qStR5OIwQdN1GZP54ci3y4mroJWjA==",
"license": "(CC-BY-4.0 AND MIT)",
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.6.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@isaacs/cliui": { "node_modules/@isaacs/cliui": {
"version": "8.0.2", "version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",

View File

@@ -18,6 +18,8 @@
"@angular/platform-browser": "^17.3.0", "@angular/platform-browser": "^17.3.0",
"@angular/platform-browser-dynamic": "^17.3.0", "@angular/platform-browser-dynamic": "^17.3.0",
"@angular/router": "^17.3.0", "@angular/router": "^17.3.0",
"@fortawesome/angular-fontawesome": "^0.14.1",
"@fortawesome/free-solid-svg-icons": "^6.6.0",
"chart.js": "^4.4.3", "chart.js": "^4.4.3",
"papaparse": "^5.4.1", "papaparse": "^5.4.1",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",

View File

@@ -16,9 +16,17 @@
[school]="school" [school]="school"
></fbi-scores> --> ></fbi-scores> -->
<fbi-cohort-section <!-- <fbi-cohort-section
*ngFor="let cohort of cohorts" *ngFor="let cohort of cohorts"
[cohort]="cohort" [cohort]="cohort"
[county]="county" [county]="county"
[school]="school" [school]="school"
></fbi-cohort-section> ></fbi-cohort-section> -->
<div class="menu">
<fbi-query class="query"></fbi-query>
<fbi-metadata class="metadata"></fbi-metadata>
</div>
<div class="content">
<fbi-result class="result"></fbi-result>
</div>

View File

@@ -1,9 +1,29 @@
:host { :host {
display: flex; display: flex;
flex-direction: column; flex-direction: row;
} }
div { div {
flex-direction: column;
display: flex;
}
.menu {
width: 200px;
height: calc(100vh - 16px);
}
.query {
height: 200px;
overflow: hidden;
}
.metadata {
flex: 1;
overflow: hidden;
}
.content {
flex: 1; flex: 1;
flex-direction: row; flex-direction: row;
display: flex; display: flex;

View File

@@ -4,7 +4,7 @@ import { ChartComponent } from '../components/chart/chart.component';
import { SelectComponent } from '../components/select/select.component'; import { SelectComponent } from '../components/select/select.component';
import { DataService } from '../services/data.service'; import { DataService } from '../services/data.service';
import { TableComponent } from '../components/table/table.component'; import { TableComponent } from '../components/table/table.component';
import { Header } from '../components/table/header'; import { Header } from '../models/header';
import { FilterService } from '../services/filters.service'; import { FilterService } from '../services/filters.service';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { Milestone } from '../enums/milestone'; import { Milestone } from '../enums/milestone';
@@ -12,6 +12,9 @@ import { ScoresComponent } from './scores/scores.component';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { CohortSectionComponent } from './cohort-section/cohort-section.component'; import { CohortSectionComponent } from './cohort-section/cohort-section.component';
import { KeyValue } from '../models/key-value'; import { KeyValue } from '../models/key-value';
import { QueryComponent } from '../components/query/query.component';
import { MetadataComponent } from '../components/metadata/metadata.component';
import { ResultComponent } from '../components/result/result.component';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
@@ -24,6 +27,9 @@ import { KeyValue } from '../models/key-value';
TableComponent, TableComponent,
ScoresComponent, ScoresComponent,
CohortSectionComponent, CohortSectionComponent,
QueryComponent,
MetadataComponent,
ResultComponent,
], ],
templateUrl: './app.component.html', templateUrl: './app.component.html',
styleUrl: './app.component.scss', styleUrl: './app.component.scss',
@@ -43,10 +49,10 @@ export class AppComponent implements OnInit {
{ label: 'School', source: Milestone.School }, { label: 'School', source: Milestone.School },
{ label: 'Grade', source: Milestone.Grade }, { label: 'Grade', source: Milestone.Grade },
].map((data) => new Header(data)); ].map((data) => new Header(data));
rows: Observable<Record<string, any>[]> = this.data.Data; rows: Observable<Record<string, any>[]> = this.data.Data$;
ngOnInit(): void { ngOnInit(): void {
this.data.Cohorts.subscribe((kv: KeyValue[]) => { this.data.Cohorts$.subscribe((kv: KeyValue[]) => {
this.cohorts = kv.map((i: KeyValue) => i.key); this.cohorts = kv.map((i: KeyValue) => i.key);
}); });
// this.filter.filters$.subscribe((filters: KeyValue[]) => { // this.filter.filters$.subscribe((filters: KeyValue[]) => {

View File

@@ -1 +1 @@
<fbi-chart [config]="chart" [data]="data"></fbi-chart> <fbi-chart [config]="chart" [data]="data" class="chart"></fbi-chart>

View File

@@ -0,0 +1,4 @@
.chart {
height: 400px;
width: 400px;
}

View File

@@ -8,6 +8,7 @@ import {
} from '../../components/chart/chart-type'; } from '../../components/chart/chart-type';
import { ChartConfig } from '../../components/chart/chart-config'; import { ChartConfig } from '../../components/chart/chart-config';
import { DataService } from '../../services/data.service'; import { DataService } from '../../services/data.service';
import { KeyValue } from '../../models/key-value';
@Component({ @Component({
selector: 'fbi-cohort-section', selector: 'fbi-cohort-section',
@@ -63,7 +64,14 @@ export class CohortSectionComponent {
Array.isArray(this.cohort) ? this.cohort : [this.cohort] Array.isArray(this.cohort) ? this.cohort : [this.cohort]
).map((v: string) => parseInt(v)); ).map((v: string) => parseInt(v));
this.dataService.Data.subscribe((data: Record<string, any>[]) => { this.dataService.Cohorts$.subscribe(
(cohorts: KeyValue[]) =>
(this.chart.title = cohorts
.map((cohort: KeyValue) => cohort.value)
.join(', '))
);
this.dataService.Data$.subscribe((data: Record<string, any>[]) => {
this.data = data this.data = data
.filter( .filter(
(d: Record<string, any>) => (d: Record<string, any>) =>

View File

@@ -80,7 +80,7 @@ export class ScoresComponent implements OnInit {
const school = this.school; const school = this.school;
const grade = this.grade; const grade = this.grade;
this.dataService.Data.subscribe((data: Record<string, any>[]) => { this.dataService.Data$.subscribe((data: Record<string, any>[]) => {
this.data = data.filter( this.data = data.filter(
(d: Record<string, any>) => (d: Record<string, any>) =>
d[Milestone.County] === county && d[Milestone.County] === county &&

View File

@@ -3,5 +3,6 @@ import { ChartAxis } from './chart-type';
export interface ChartConfig { export interface ChartConfig {
type: ChartType; type: ChartType;
title?: string;
axis: ChartAxis[]; axis: ChartAxis[];
} }

View File

@@ -32,6 +32,7 @@ export interface IDataset {
xAxisKey?: string; xAxisKey?: string;
yAxisKey?: string; yAxisKey?: string;
}; };
spanGaps?: boolean;
stack?: string; stack?: string;
} }

View File

@@ -1 +1 @@
<canvas #chart></canvas> <canvas #chart class="fit"></canvas>

View File

@@ -0,0 +1,4 @@
.fit {
height: 100%;
width: 100%;
}

View File

@@ -46,6 +46,7 @@ export class DatasetService {
if (axis.fill) { if (axis.fill) {
entry.fill = axis.fill; entry.fill = axis.fill;
} }
entry.spanGaps = true;
result.push(entry); result.push(entry);
}); });

View File

@@ -15,11 +15,18 @@ export class OptionsService {
result.maintainAspectRatio = false; result.maintainAspectRatio = false;
result.scales = this.scaleService.scales(chart, data); result.scales = this.scaleService.scales(chart, data);
if (chart.title) {
result.plugins = result.plugins ?? {};
result.plugins.title = result.plugins.title ?? {};
result.plugins.title.text = chart.title;
}
const fill = const fill =
(chart?.axis ?? []).filter((axis: ChartAxis) => axis.fill).length > 0; (chart?.axis ?? []).filter((axis: ChartAxis) => axis.fill).length > 0;
if (fill) { if (fill) {
result.plugins = result.plugins ?? {}; result.plugins = result.plugins ?? {};
result.plugins.filler = { propagate: true }; result.plugins.filler = result.plugins.filler ?? {};
result.plugins.filler.propagate = true;
} }
return result; return result;

View File

@@ -0,0 +1 @@
<p>context-menu works!</p>

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ContextMenuComponent } from './context-menu.component';
describe('ContextMenuComponent', () => {
let component: ContextMenuComponent;
let fixture: ComponentFixture<ContextMenuComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ContextMenuComponent]
})
.compileComponents();
fixture = TestBed.createComponent(ContextMenuComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,12 @@
import { Component } from '@angular/core';
@Component({
selector: 'fbi-context-menu',
standalone: true,
imports: [],
templateUrl: './context-menu.component.html',
styleUrl: './context-menu.component.scss'
})
export class ContextMenuComponent {
}

View File

@@ -0,0 +1,6 @@
<div class="header">Fields!</div>
<fbi-tree
class="tree"
[node]="node"
(actionClick)="onActionClick($event)"
></fbi-tree>

View File

@@ -0,0 +1,17 @@
:host {
display: flex;
flex-direction: column;
}
.header {
position: sticky;
top: 0px;
border: 1px solid black;
padding-left: 4px;
background-color: lightgray;
}
.tree {
flex: 1;
overflow: scroll;
}

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MetadataComponent } from './metadata.component';
describe('MetadataComponent', () => {
let component: MetadataComponent;
let fixture: ComponentFixture<MetadataComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [MetadataComponent]
})
.compileComponents();
fixture = TestBed.createComponent(MetadataComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,75 @@
import { Component } from '@angular/core';
import { TreeComponent } from '../tree/tree.component';
import { TreeNode } from '../../models/tree-node';
import { MetaService } from '../../services/meta.service';
import { Action } from '../../models/action';
import { faAdd } from '@fortawesome/free-solid-svg-icons';
import { QueryService } from '../../services/query.service';
import { combineLatest } from 'rxjs';
import { Query } from '../../models/query';
@Component({
selector: 'fbi-metadata',
standalone: true,
imports: [TreeComponent],
templateUrl: './metadata.component.html',
styleUrl: './metadata.component.scss',
})
export class MetadataComponent {
node: TreeNode = new TreeNode({});
constructor(metaService: MetaService, private queryService: QueryService) {
combineLatest({
meta: metaService.Data,
query: queryService.Query,
}).subscribe((d: { meta: Partial<TreeNode>[]; query: Query }) => {
const inuse = d.query.fields;
const expanded = this.getExpanded(this.node);
const recurse = (node: Partial<TreeNode>) => {
node.hidden = inuse.includes(node.data);
node.expanded = expanded.includes(node.data);
const children = node.children ?? [];
children.forEach((child: Partial<TreeNode>) => recurse(child));
if (children.length === 0) {
const actions = [{ label: 'Add', icon: faAdd, data: ACTIONS.ADD }];
node.actions = actions as Action[];
}
};
(d.meta ?? []).forEach((node: Partial<TreeNode>) => recurse(node));
this.node = new TreeNode({
hidden: true,
expanded: true,
children: d.meta as TreeNode[],
});
});
}
onActionClick(event: { action: Action; node: TreeNode }): void {
switch (event.action.data) {
case ACTIONS.ADD:
this.queryService.add(event.node.data);
break;
}
}
private getExpanded(node: TreeNode): string[] {
const result: string[] = [];
if (node.expanded) {
result.push(node.data);
}
(node.children ?? []).forEach((child: TreeNode) =>
result.push(...this.getExpanded(child))
);
return result;
}
}
enum ACTIONS {
ADD = 'add',
}

View File

@@ -0,0 +1,6 @@
<div class="header">Query!</div>
<fbi-tree
class="tree"
[node]="node"
(actionClick)="onActionClick($event)"
></fbi-tree>

View File

@@ -0,0 +1,17 @@
:host {
display: flex;
flex-direction: column;
}
.header {
position: sticky;
top: 0px;
border: 1px solid black;
padding-left: 4px;
background-color: lightgray;
}
.tree {
flex: 1;
overflow: scroll;
}

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { QueryComponent } from './query.component';
describe('QueryComponent', () => {
let component: QueryComponent;
let fixture: ComponentFixture<QueryComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [QueryComponent]
})
.compileComponents();
fixture = TestBed.createComponent(QueryComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,106 @@
import { Component } from '@angular/core';
import { TreeComponent } from '../tree/tree.component';
import { TreeNode } from '../../models/tree-node';
import { QueryService } from '../../services/query.service';
import { Query } from '../../models/query';
import { Action } from '../../models/action';
import {
faArrowDown,
faArrowUp,
faRemove,
} from '@fortawesome/free-solid-svg-icons';
import { ExecuteService } from '../../services/execute.service';
import { Header } from '../../models/header';
@Component({
selector: 'fbi-query',
standalone: true,
imports: [TreeComponent],
templateUrl: './query.component.html',
styleUrl: './query.component.scss',
})
export class QueryComponent {
node: TreeNode = new TreeNode({});
constructor(
private queryService: QueryService,
executeService: ExecuteService
) {
queryService.Query.subscribe((query: Query) => {
executeService.headers(query).subscribe((headers: Header[]) => {
this.node = new TreeNode({
hidden: true,
expanded: true,
children: [
{
label: 'Fields',
leaf: false,
expanded: true,
children: (query?.fields ?? []).map(
(field: string, index: number, array: string[]) => {
const actions = [];
if (index !== 0) {
actions.push({
label: 'Move Up',
icon: faArrowUp,
data: { cmd: ACTIONS.UP, data: index - 1 },
});
}
if (index !== array.length - 1) {
actions.push({
label: 'Move Down',
icon: faArrowDown,
data: { cmd: ACTIONS.DOWN, data: index + 1 },
});
}
actions.push({
label: 'Remove',
icon: faRemove,
data: { cmd: ACTIONS.REMOVE },
});
const label =
headers.find((header: Header) => header.source === field)
?.label ?? 'unknown';
return {
label: label,
data: field,
actions: actions,
};
}
),
},
{ label: 'Filters', expanded: true, leaf: false },
] as TreeNode[],
});
});
});
}
onActionClick(event: { action: Action; node: TreeNode }): void {
const data = event.action.data as ActionData;
switch (data.cmd) {
case ACTIONS.REMOVE:
this.queryService.remove(event.node.data);
break;
case ACTIONS.UP:
this.queryService.add(event.node.data, data.data);
break;
case ACTIONS.DOWN:
this.queryService.add(event.node.data, data.data);
break;
}
}
}
enum ACTIONS {
REMOVE = 'del',
UP = 'up',
DOWN = 'down',
}
type ActionData = {
cmd: ACTIONS;
data: any;
};

View File

@@ -0,0 +1 @@
<fbi-table [headers]="headers" [rows]="rows"></fbi-table>

View File

@@ -0,0 +1,3 @@
:host {
padding: 8px;
}

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ResultComponent } from './result.component';
describe('ResultComponent', () => {
let component: ResultComponent;
let fixture: ComponentFixture<ResultComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ResultComponent]
})
.compileComponents();
fixture = TestBed.createComponent(ResultComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,49 @@
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { TreeComponent } from '../tree/tree.component';
import { QueryService } from '../../services/query.service';
import { Query } from '../../models/query';
import { ExecuteService } from '../../services/execute.service';
import { forkJoin } from 'rxjs';
import { Header } from '../../models/header';
import { TableComponent } from '../table/table.component';
@Component({
selector: 'fbi-result',
standalone: true,
imports: [CommonModule, TreeComponent, TableComponent],
templateUrl: './result.component.html',
styleUrl: './result.component.scss',
})
export class ResultComponent {
headers: Header[] = [];
rows: Record<string, any>[] = [];
constructor(
queryService: QueryService,
private executeService: ExecuteService
) {
let last: string = '';
queryService.Query.subscribe((query: Query) => {
if (query.isValid()) {
const current = query.toString();
if (last !== current) {
this.load(query);
last = current;
}
}
});
}
private load(query: Query): void {
forkJoin({
headers: this.executeService.headers(query),
data: this.executeService.data(query),
}).subscribe(
(result: { headers: Header[]; data: Record<string, any>[] }) => {
this.headers = result.headers;
this.rows = result.data;
}
);
}
}

View File

@@ -1,12 +1,13 @@
<table> <table>
<tr> <tr>
<th *ngFor="let h of header"> <th></th>
<th *ngFor="let h of headers">
{{ h.label }} {{ h.label }}
</th> </th>
</tr> </tr>
<tr *ngFor="let row of rows | async; let index"> <tr *ngFor="let row of rows; index as i">
<td>{{ index }}</td> <td>{{ i }}</td>
<td *ngFor="let h of header"> <td *ngFor="let h of headers">
{{ row[h.source] }} {{ row[h.source] }}
</td> </td>
</tr> </tr>

View File

@@ -1,7 +1,6 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Component, Input } from '@angular/core'; import { Component, Input } from '@angular/core';
import { Header } from './header'; import { Header } from '../../models/header';
import { Observable } from 'rxjs';
@Component({ @Component({
selector: 'fbi-table', selector: 'fbi-table',
@@ -11,6 +10,6 @@ import { Observable } from 'rxjs';
styleUrl: './table.component.scss', styleUrl: './table.component.scss',
}) })
export class TableComponent { export class TableComponent {
@Input() header!: Header[]; @Input() headers!: Header[];
@Input() rows!: Observable<Record<string, any>[]>; @Input() rows!: Record<string, any>[];
} }

View File

@@ -0,0 +1,32 @@
<div
*ngIf="!node?.hidden"
class="treenode"
[class.disabled]="node.disabled"
[class.clickable]="!node.leaf"
(click)="toggle($event)"
>
<span *ngIf="!node.leaf" class="icon">
<fa-icon *ngIf="node.expanded" [icon]="faCaretDown"></fa-icon>
<fa-icon *ngIf="!node.expanded" [icon]="faCaretRight"></fa-icon>
</span>
<span [title]="node.label" class="fill ellipsis">{{ node.label }}</span>
<ng-container *ngIf="node.leaf && !node.disabled" class="actions">
<fa-icon
*ngFor="let action of node.actions"
class="action clickable"
[icon]="action.icon"
(click)="onActionClick($event, action)"
></fa-icon>
</ng-container>
</div>
<div
*ngIf="node && node.children && node.expanded"
[class.branch]="node.label !== ''"
>
<fbi-tree
*ngFor="let child of node.children"
[node]="child"
(actionClick)="passActionClick($event)"
></fbi-tree>
</div>

View File

@@ -0,0 +1,56 @@
.branch {
margin-left: 20px;
overflow: hidden;
}
.treenode {
color: #333;
margin: 0;
white-space: nowrap;
display: flex;
margin-right: 2px;
}
.treenode:hover {
background-color: #edf8fa;
}
.icon {
display: inline-block;
width: 16px;
text-align: center;
}
.fill {
flex: 1;
}
.clickable {
cursor: pointer;
}
.disabled {
color: dimgray;
opacity: 0.5;
}
.right {
float: right;
}
.ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.treenode:hover > .action {
visibility: visible;
width: auto;
}
.action {
visibility: hidden;
width: 0px;
padding-left: 2px;
}

View File

@@ -0,0 +1,22 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TreeComponent } from './tree.component';
describe('TreeComponent', () => {
let component: TreeComponent;
let fixture: ComponentFixture<TreeComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TreeComponent],
}).compileComponents();
fixture = TestBed.createComponent(TreeComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,40 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { TreeNode } from '../../models/tree-node';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import { faCaretDown, faCaretRight } from '@fortawesome/free-solid-svg-icons';
import { Action } from '../../models/action';
@Component({
selector: 'fbi-tree',
standalone: true,
imports: [CommonModule, FontAwesomeModule],
templateUrl: './tree.component.html',
styleUrl: './tree.component.scss',
})
export class TreeComponent {
@Input() node!: TreeNode;
@Output() actionClick = new EventEmitter<{
action: Action;
node: TreeNode;
}>();
faCaretDown = faCaretDown;
faCaretRight = faCaretRight;
toggle(event: MouseEvent): void {
if (!this.node.leaf) {
event.stopPropagation();
this.node.expanded = !this.node.expanded;
}
}
passActionClick(event: { action: Action; node: TreeNode }): void {
this.actionClick.emit(event);
}
onActionClick(event: MouseEvent, action: Action): void {
event.stopPropagation();
this.actionClick.emit({ action: action, node: this.node });
}
}

13
src/models/action.ts Normal file
View File

@@ -0,0 +1,13 @@
import { faDumbbell, IconDefinition } from '@fortawesome/free-solid-svg-icons';
export class Action {
label: string;
icon: IconDefinition;
data: any;
constructor(data: Partial<Action>) {
this.label = data?.label ?? '';
this.icon = data?.icon ?? faDumbbell;
this.data = data?.data;
}
}

13
src/models/page.ts Normal file
View File

@@ -0,0 +1,13 @@
export class Page {
start: number;
limit: number;
constructor(data: Partial<Page>) {
this.start = data?.start ?? 1;
this.limit = data?.limit ?? 20;
}
toString(): string {
return `(${this.start}:${this.limit})`;
}
}

23
src/models/query.ts Normal file
View File

@@ -0,0 +1,23 @@
import { Page } from './page';
export class Query {
fields: string[];
filter: string[];
page: Page;
constructor(data: Partial<Query>) {
this.fields = data?.fields ?? [];
this.filter = data?.filter ?? [];
this.page = new Page(data?.page ?? {});
}
isValid(): boolean {
return true;
}
toString(): string {
const fields = (this.fields ?? []).join(', ');
const filters = '';
return `${fields}${filters}`;
}
}

38
src/models/tree-node.ts Normal file
View File

@@ -0,0 +1,38 @@
import { Action } from './action';
export class TreeNode {
label: string;
data: any;
children: TreeNode[];
leaf: boolean;
expanded: boolean;
hidden: boolean;
disabled: boolean;
selected: boolean;
actions: Action[];
index: number;
isFirst: boolean;
isLast: boolean;
constructor(data: Partial<TreeNode>) {
this.label = data?.label ?? '';
this.data = data?.data ?? null;
this.children = (data?.children ?? []).map(
(i: Partial<TreeNode>, index: number) =>
new TreeNode({ ...i, ...{ index: index } })
);
this.leaf = data?.leaf ?? this.children.length === 0;
this.expanded = data?.expanded ?? false;
this.hidden = data?.hidden ?? false;
this.disabled = data?.disabled ?? false;
this.selected = data?.selected ?? false;
this.actions = (data?.actions ?? []).map(
(i: Partial<Action>) => new Action(i)
);
this.index = data?.index ?? 0;
this.isFirst = data?.isFirst ?? false;
this.isLast = data?.isLast ?? false;
}
}

View File

@@ -8,33 +8,38 @@ import { Milestone } from '../enums/milestone';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class DataService { export class DataService {
private _data: Record<string, any>[] = []; private _data: Record<string, any>[] = [];
private data = new ReplaySubject<Record<string, any>[]>(); private data = new ReplaySubject<Record<string, any>[]>(1);
readonly Data = this.data.asObservable(); readonly Data$ = this.data.asObservable();
readonly Data = () => this._data;
private _counties = new Map<string, string>(); private _counties = new Map<string, string>();
private counties = new ReplaySubject<KeyValue[]>(); private counties = new ReplaySubject<KeyValue[]>(1);
readonly Counties = this.counties.asObservable(); readonly Counties$ = this.counties.asObservable();
readonly Counties = () => this._counties;
private _schools = new Map<string, string>(); private _schools = new Map<string, string>();
private schools = new ReplaySubject<KeyValue[]>(); private schools = new ReplaySubject<KeyValue[]>(1);
readonly Schools = this.schools.asObservable(); readonly Schools$ = this.schools.asObservable();
readonly Schools = () => this._schools;
private _years = Array(2023 - 2015 + 1) private _years = Array(2023 - 2015 + 1)
.fill(0) .fill(0)
.map((_, index) => 2015 + index) .map((_, index) => 2015 + index);
.filter((y) => y !== 2020); private years = new ReplaySubject<KeyValue[]>(1);
private years = new ReplaySubject<KeyValue[]>(); readonly Years$ = this.years.asObservable();
readonly Years = this.years.asObservable(); readonly Years = () => this._years;
private _grades = Array(8 - 3 + 1) private _grades = Array(8 - 3 + 1)
.fill(0) .fill(0)
.map((_, index) => 3 + index); .map((_, index) => 3 + index);
private grades = new ReplaySubject<KeyValue[]>(); private grades = new ReplaySubject<KeyValue[]>(1);
readonly Grades = this.grades.asObservable(); readonly Grades$ = this.grades.asObservable();
readonly Grades = () => this._grades;
private _cohorts = new Map<string, string>(); private _cohorts = new Map<string, string>();
private cohorts = new ReplaySubject<KeyValue[]>(); private cohorts = new ReplaySubject<KeyValue[]>(1);
readonly Cohorts = this.cohorts.asObservable(); readonly Cohorts$ = this.cohorts.asObservable();
readonly Cohorts = () => this._cohorts;
constructor() { constructor() {
this.load(); this.load();
@@ -44,7 +49,7 @@ export class DataService {
let count = this._years.length * this._grades.length; let count = this._years.length * this._grades.length;
this._years.forEach((year: number) => { this._years.forEach((year: number) => {
this._grades.forEach((grade: number) => { this._grades.forEach((grade: number) => {
Papa.parse(`assets/data/${year}-${grade}.csv`, { Papa.parse(`assets/data/${year === 2020 ? 2019 : year}-${grade}.csv`, {
download: true, download: true,
header: false, header: false,
complete: (results: ParseResult<Record<string, any>>) => { complete: (results: ParseResult<Record<string, any>>) => {
@@ -68,12 +73,12 @@ export class DataService {
if (isNaN(code)) return; if (isNaN(code)) return;
const ms = {} as Record<string, any>; const ms = {} as Record<string, any>;
// const ms = new Milestone({});
ms[Milestone.Year] = year; ms[Milestone.Year] = year;
ms[Milestone.Grade] = grade; ms[Milestone.Grade] = grade;
ms[Milestone.Cohort] = cohort; ms[Milestone.Cohort] = cohort;
ms[Milestone.County] = fs(Csv.County_Code, record); ms[Milestone.County] = fs(Csv.County_Code, record);
ms[Milestone.School] = fs(Csv.School_Code, record); ms[Milestone.School] = fs(Csv.School_Code, record);
if (year !== 2020) {
ms[Milestone.ELACount] = fn(Csv.ELA_Count, record); ms[Milestone.ELACount] = fn(Csv.ELA_Count, record);
ms[Milestone.ELAMean] = fn(Csv.ELA_Mean, record); ms[Milestone.ELAMean] = fn(Csv.ELA_Mean, record);
ms[Milestone.ELA0] = fn(Csv.ELA_L0, record); ms[Milestone.ELA0] = fn(Csv.ELA_L0, record);
@@ -98,6 +103,7 @@ export class DataService {
ms[Milestone.Soc1] = fn(Csv.Social_L1, record); ms[Milestone.Soc1] = fn(Csv.Social_L1, record);
ms[Milestone.Soc2] = fn(Csv.Social_L2, record); ms[Milestone.Soc2] = fn(Csv.Social_L2, record);
ms[Milestone.Soc3] = fn(Csv.Social_L3, record); ms[Milestone.Soc3] = fn(Csv.Social_L3, record);
}
data.push(ms); data.push(ms);
const county = fs(Csv.County_Code, record); const county = fs(Csv.County_Code, record);
@@ -108,6 +114,7 @@ export class DataService {
); );
}); });
if (year !== 2020) {
[ [
{ sort: Milestone.ELAMean, rank: Milestone.ELARank }, { sort: Milestone.ELAMean, rank: Milestone.ELARank },
{ sort: Milestone.MathMean, rank: Milestone.MathRank }, { sort: Milestone.MathMean, rank: Milestone.MathRank },
@@ -120,9 +127,11 @@ export class DataService {
+a[m.sort] - b[m.sort] +a[m.sort] - b[m.sort]
) )
.forEach( .forEach(
(a: Record<string, any>, index: number) => (a[m.rank] = index) (a: Record<string, any>, index: number) =>
(a[m.rank] = index)
); );
}); });
}
this._data.push(...data); this._data.push(...data);

View File

@@ -0,0 +1,81 @@
import { Injectable } from '@angular/core';
import { DataService } from './data.service';
import { Query } from '../models/query';
import { Observable, of, map, take } from 'rxjs';
import { Header } from '../models/header';
import { MetaService } from './meta.service';
@Injectable({ providedIn: 'root' })
export class ExecuteService {
constructor(
private dataService: DataService,
private metaService: MetaService
) {}
headers(query: Query): Observable<Header[]> {
if (!query?.isValid()) return of([]);
const fields = query.fields;
return this.metaService.Flat.pipe(
map((items: Header[]) =>
items
.filter((item: Header) => fields.includes(item.source))
.sort(
(a: Header, b: Header) =>
fields.indexOf(a.source) - fields.indexOf(b.source)
)
),
take(1)
);
}
data(query: Query): Observable<Record<string, any>[]> {
if (!query?.isValid()) return of([]);
const fields = query.fields;
return this.dataService.Data$.pipe(
// apply filter
map((data: Record<string, any>[]) => {
const filter = query.filter;
return data.filter((i: Record<string, any>) => {
// apply filter to i to determine if it should be returned
return true;
});
}),
// apply fields
map((data: Record<string, any>[]) =>
data.map((i: Record<string, any>) => {
const r = {} as Record<string, any>;
fields.forEach((field: string) => (r[field] = i[field]));
return r;
})
),
// make the data unique and apply paging
map((data: Record<string, any>[]) => {
let i = 0;
const result: Record<string, any>[] = [];
const page = query.page;
data.some((d: Record<string, any>) => {
const idx = result.findIndex((r: Record<string, any>) =>
fields
.map((field: string) => d[field] === r[field])
.reduce((acc, cur) => acc && cur, true)
);
// if this element has not been seen before, add it to the results we care about.
if (idx < 0) {
result.push(d);
++i;
}
return i >= page.start + page.limit;
});
return result.slice(page.start, page.start + page.limit);
}),
// force it to close
take(1)
);
}
}

View File

@@ -0,0 +1,110 @@
import { Injectable } from '@angular/core';
import { Milestone } from '../enums/milestone';
import { map, ReplaySubject } from 'rxjs';
import { TreeNode } from '../models/tree-node';
import { Header } from '../models/header';
@Injectable({ providedIn: 'root' })
export class MetaService {
private data = [
{
label: 'School',
data: 'node.school',
children: [
{ data: Milestone.County, label: 'County' },
{ data: Milestone.School, label: 'Name' },
],
},
{
label: 'Class',
data: 'node.class',
children: [
{ data: Milestone.Cohort, label: 'Cohort' },
{ data: Milestone.Year, label: 'Year' },
{ data: Milestone.Grade, label: 'Grade' },
],
},
{
label: 'English Language Arts',
data: 'node.ela',
children: [
{ data: Milestone.ELACount, label: 'Count' },
{ data: Milestone.ELAMean, label: 'Mean' },
{ data: Milestone.ELARank, label: 'Rank' },
{ data: Milestone.ELA0, label: 'Beginning Learner %' },
{ data: Milestone.ELA1, label: 'Developing Learner %' },
{ data: Milestone.ELA2, label: 'Proficient Learner %' },
{ data: Milestone.ELA3, label: 'Advanced Learner %' },
],
},
{
label: 'Mathematics',
data: 'node.math',
children: [
{ data: Milestone.MathCount, label: 'Count' },
{ data: Milestone.MathMean, label: 'Mean' },
{ data: Milestone.MathRank, label: 'Rank' },
{ data: Milestone.Math0, label: 'Beginning Learner %' },
{ data: Milestone.Math1, label: 'Developing Learner %' },
{ data: Milestone.Math2, label: 'Proficient Learner %' },
{ data: Milestone.Math3, label: 'Advanced Learner %' },
],
},
{
label: 'Science',
data: 'node.sci',
children: [
{ data: Milestone.SciCount, label: 'Count' },
{ data: Milestone.SciMean, label: 'Mean' },
{ data: Milestone.SciRank, label: 'Rank' },
{ data: Milestone.Sci0, label: 'Beginning Learner %' },
{ data: Milestone.Sci1, label: 'Developing Learner %' },
{ data: Milestone.Sci2, label: 'Proficient Learner %' },
{ data: Milestone.Sci3, label: 'Advanced Learner %' },
],
},
{
label: 'Social Studies',
data: 'node.soc',
children: [
{ data: Milestone.SocCount, label: 'Count' },
{ data: Milestone.SocMean, label: 'Mean' },
{ data: Milestone.SocRank, label: 'Rank' },
{ data: Milestone.Soc0, label: 'Beginning Learner %' },
{ data: Milestone.Soc1, label: 'Developing Learner %' },
{ data: Milestone.Soc2, label: 'Proficient Learner %' },
{ data: Milestone.Soc3, label: 'Advanced Learner %' },
],
},
];
private subject = new ReplaySubject<Partial<TreeNode>[]>(1);
readonly Data = this.subject.asObservable();
readonly Flat = this.subject.asObservable().pipe(
map((data: Partial<TreeNode>[]) => {
const recurse = (path: string, item: Partial<TreeNode>): Header[] => {
const result = [];
const children = item?.children ?? [];
if (children.length > 0) {
children.forEach((child: Partial<TreeNode>) => {
result.push(...recurse(`${path}${item.label}`, child));
});
} else {
result.push(
new Header({ source: item.data, label: `${path} - ${item.label}` })
);
}
return result;
};
const result: Header[] = [];
data.forEach((item: Partial<TreeNode>) =>
result.push(...recurse('', item))
);
return result;
})
);
constructor() {
this.subject.next(this.data as Partial<TreeNode>[]);
}
}

View File

@@ -0,0 +1,24 @@
import { BehaviorSubject } from 'rxjs';
import { Query } from '../models/query';
import { Injectable } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class QueryService {
private querySubject = new BehaviorSubject<Query>(new Query({}));
readonly Query = this.querySubject.asObservable();
constructor() {}
add(field: string, index: number = -1): void {
const q = new Query(this.querySubject.value);
q.fields = q.fields.filter((f: string) => f !== field);
q.fields.splice(index < 0 ? q.fields.length : index, 0, field);
this.querySubject.next(q);
}
remove(field: string): void {
const q = new Query(this.querySubject.value);
q.fields = q.fields.filter((f: string) => f !== field);
this.querySubject.next(q);
}
}

View File

@@ -1 +1,5 @@
/* You can add global styles to this file, and also import other style files */ /* You can add global styles to this file, and also import other style files */
body {
// margin: 0;
font-family: Arial, Helvetica, sans-serif;
}