Compare commits

...

2 Commits

21 changed files with 437 additions and 166 deletions

View File

@@ -1 +1,10 @@
<fbi-chart></fbi-chart> <fbi-select></fbi-select> <div>
<fbi-select label="Class" key="cohort" [data]="data.Cohorts"></fbi-select>
<fbi-select label="Year" key="year" [data]="data.Years"></fbi-select>
<fbi-select label="Grade" key="grade" [data]="data.Grades"></fbi-select>
<fbi-select label="County" key="county" [data]="data.Counties"></fbi-select>
<fbi-select label="School" key="school" [data]="data.Schools"></fbi-select>
</div>
<div>
<fbi-table [header]="header" [rows]="rows"></fbi-table>
</div>

View File

@@ -0,0 +1,10 @@
:host {
display: flex;
flex-direction: column;
}
div {
flex: 1;
flex-direction: row;
display: flex;
}

View File

@@ -1,15 +1,49 @@
import { Component } from '@angular/core'; import { Component, OnInit, inject } from '@angular/core';
import { RouterOutlet } from '@angular/router'; import { RouterOutlet } from '@angular/router';
import { ChartComponent } from '../components/chart/chart.component'; 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 { TableComponent } from '../components/table/table.component';
import { Header } from '../components/table/header';
import { FilterService } from '../services/filters.service';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
standalone: true, standalone: true,
imports: [RouterOutlet, ChartComponent, SelectComponent], imports: [RouterOutlet, ChartComponent, SelectComponent, TableComponent],
templateUrl: './app.component.html', templateUrl: './app.component.html',
styleUrl: './app.component.scss', styleUrl: './app.component.scss',
}) })
export class AppComponent { export class AppComponent implements OnInit {
title = 'Georgia Milestones'; title = 'Georgia Milestones';
data = inject(DataService);
filter = inject(FilterService);
header = [new Header({ label: 'School', source: 'school' })];
rows: Record<string, any>[] = [];
ngOnInit(): void {
// this.filter.filters$.subscribe((filters: KeyValue[]) => {
// this.data.Data.pipe(take(1)).subscribe((data: Milestone[]) => {
// const result = data.filter((d: Milestone) => {
// const match = (filters ?? []).findIndex((f: KeyValue) => {
// switch (f.key) {
// case 'year':
// return d.Year.toString() === f.value;
// case 'grade':
// return d.Grade.toString() === f.value;
// case 'county':
// return d.County === f.value;
// case 'school':
// return d.School === f.value;
// }
// return false;
// });
// return match > -1;
// });
// this.rows = result.map((r: Milestone) => ({ school: r.School }));
// });
// });
}
} }

View File

@@ -12,6 +12,7 @@ export enum ChartType {
export interface IDataset { export interface IDataset {
type?: ChartType; type?: ChartType;
borderColor?: string; borderColor?: string;
label?: string;
data: string[]; data: string[];
backgroundColor?: string | string[]; backgroundColor?: string | string[];
pointBackgroundColor?: string; pointBackgroundColor?: string;
@@ -20,6 +21,6 @@ export interface IDataset {
export interface IData { export interface IData {
labels?: string[]; labels?: string[];
labelsSource: string[]; labelsSource?: string[];
datasets: IDataset[]; datasets: IDataset[];
} }

View File

@@ -5,7 +5,7 @@ import {
OnDestroy, OnDestroy,
ViewChild, ViewChild,
} from '@angular/core'; } from '@angular/core';
import { Chart, ChartOptions } from 'chart.js'; import { Chart, ChartOptions } from 'chart.js/auto';
import { PluginNodata } from './plugin-nodata'; import { PluginNodata } from './plugin-nodata';
import { PluginMoreColors } from './plugin-more-colors'; import { PluginMoreColors } from './plugin-more-colors';
import { ChartType, IData } from './chart-type'; import { ChartType, IData } from './chart-type';
@@ -22,9 +22,20 @@ export class ChartComponent implements AfterViewInit, OnDestroy {
private chart: any = undefined; private chart: any = undefined;
private data: IData = { private data: IData = {
labels: [], labels: Array.from(
labelsSource: [], { length: 10 },
datasets: [], () => `${Math.floor(Math.random() * 100)}`
),
// labelsSource: [],
datasets: [
{
label: 'test',
data: Array.from(
{ length: 10 },
() => `${Math.floor(Math.random() * 100)}`
),
},
],
}; };
constructor() {} constructor() {}

View File

@@ -1,4 +1,7 @@
<label for="select">{{ label }}</label> <select name="select" (change)="onChange($event)">
<select name="select"> <option selected disabled hidden value="">{{ label }}</option>
<option *ngFor="let item of data" [value]="item">{{ item }}</option> <option *ngFor="let kv of data | async" [value]="kv.key">
{{ kv.value }}
</option>
</select> </select>
<!-- <label for="select">{{ label }}</label> -->

View File

@@ -0,0 +1,14 @@
:host {
display: block;
width: 200px;
}
// label {
// padding-right: 10px;
// float: right;
// }
select {
width: 200px;
float: right;
}

View File

@@ -1,6 +1,8 @@
import { Component, Input, inject } from '@angular/core'; import { Component, Input, inject } from '@angular/core';
import { DataService } from '../../services/data.service';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Observable } from 'rxjs';
import { KeyValue } from '../../models/key-value';
import { FilterService } from '../../services/filters.service';
@Component({ @Component({
selector: 'fbi-select', selector: 'fbi-select',
@@ -11,7 +13,12 @@ import { CommonModule } from '@angular/common';
}) })
export class SelectComponent { export class SelectComponent {
@Input() label!: string; @Input() label!: string;
@Input() data!: (string | number)[]; @Input() key!: string;
@Input() data!: Observable<KeyValue[]>;
private dataService = inject(DataService); private filterService = inject(FilterService);
onChange(event: any): void {
this.filterService.set(this.key, event?.target?.value);
}
} }

View File

@@ -0,0 +1,9 @@
export class Header {
label: string;
source: string;
constructor(data: Partial<Header>) {
this.label = data?.label ?? '';
this.source = data?.source ?? '';
}
}

View File

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

View File

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

View File

@@ -0,0 +1,15 @@
import { CommonModule } from '@angular/common';
import { Component, Input } from '@angular/core';
import { Header } from './header';
@Component({
selector: 'fbi-table',
standalone: true,
imports: [CommonModule],
templateUrl: './table.component.html',
styleUrl: './table.component.scss',
})
export class TableComponent {
@Input() header!: Header[];
@Input() rows!: Record<string, any>[];
}

View File

@@ -1,28 +0,0 @@
export enum Column {
County = 2,
School = 3,
ELA_Count = 4,
ELA_Mean = 5,
ELA_0 = 6,
ELA_1 = 7,
ELA_2 = 8,
ELA_3 = 9,
Math_Count = 12,
Math_Mean = 13,
Math_0 = 14,
Math_1 = 15,
Math_2 = 16,
Math_3 = 17,
Sci_Count = 20,
Sci_Mean = 21,
Sci_0 = 22,
Sci_1 = 23,
Sci_2 = 24,
Sci_3 = 25,
Soc_Count = 28,
Soc_Mean = 29,
Soc_0 = 30,
Soc_1 = 31,
Soc_2 = 32,
Soc_3 = 33,
}

30
src/enums/csv.ts Normal file
View File

@@ -0,0 +1,30 @@
export enum Csv {
School_Code = 'School Code',
School_Label = 'School Name',
County_Code = 'System Code',
County_Label = 'System Name',
ELA_Count = 'English Language Arts Number Tested',
ELA_Mean = 'English Language Arts Mean Scale Score',
ELA_L0 = 'English Language Arts % Beginning Learner',
ELA_L1 = 'English Language Arts % Developing Learner',
ELA_L2 = 'English Language Arts % Proficient Learner',
ELA_L3 = 'English Language Arts % Distinguished Learner',
Math_Count = 'Mathematics Number Tested',
Math_Mean = 'Mathematics Mean Scale Score',
Math_L0 = 'Mathematics % Beginning Learner',
Math_L1 = 'Mathematics % Developing Learner',
Math_L2 = 'Mathematics % Proficient Learner',
Math_L3 = 'Mathematics % Distinguished Learner',
Science_Count = 'Science Number Tested',
Science_Mean = 'Science Mean Scale Score',
Science_L0 = 'Science % Beginning Learner',
Science_L1 = 'Science % Developing Learner',
Science_L2 = 'Science % Proficient Learner',
Science_L3 = 'Science % Distinguished Learner',
Social_Count = 'Social Studies Number Tested',
Social_Mean = 'Social Studies Mean Scale Score',
Social_L0 = 'Social Studies % Beginning Learner',
Social_L1 = 'Social Studies % Developing Learner',
Social_L2 = 'Social Studies % Proficient Learner',
Social_L3 = 'Social Studies % Distinguished Learner',
}

35
src/enums/milestone.ts Normal file
View File

@@ -0,0 +1,35 @@
export enum Milestone {
Cohort = 'cohort',
Year = 'year',
County = 'mcode',
School = 'lcode',
Grade = 'grade',
ELACount = 'ecount',
ELAMean = 'emean',
ELA0 = 'ela0',
ELA1 = 'ela1',
ELA2 = 'ela2',
ELA3 = 'ela3',
ELARank = 'elarank',
MathCount = 'mcount',
MathMean = 'mmean',
Math0 = 'math0',
Math1 = 'math1',
Math2 = 'math2',
Math3 = 'math3',
MathRank = 'mathrank',
SciCount = 'ccount',
SciMean = 'cmean',
Sci0 = 'sci0',
Sci1 = 'sci1',
Sci2 = 'sci2',
Sci3 = 'sci3',
SciRank = 'scirank',
SocCount = 'ocount',
SocMean = 'omean',
Soc0 = 'soc0',
Soc1 = 'soc1',
Soc2 = 'soc2',
Soc3 = 'soc3',
SocRank = 'socrank',
}

9
src/models/key-value.ts Normal file
View File

@@ -0,0 +1,9 @@
export class KeyValue {
key: string;
value: string;
constructor(data: Partial<KeyValue>) {
this.key = data?.key ?? '';
this.value = data?.value ?? '';
}
}

View File

@@ -1,23 +0,0 @@
import { Score } from './score';
export class Milestone {
Year: number;
Grade: number;
County: string;
School: string;
ELA: Score;
Math: Score;
Science: Score;
Social: Score;
constructor(data: Partial<Milestone>) {
this.Year = data?.Year ?? 2000;
this.Grade = data?.Grade ?? 0;
this.County = data?.County ?? '';
this.School = data?.School ?? '';
this.ELA = new Score(data?.ELA ?? {});
this.Math = new Score(data?.Math ?? {});
this.Science = new Score(data?.Science ?? {});
this.Social = new Score(data?.Social ?? {});
}
}

View File

@@ -1,17 +0,0 @@
export class Score {
Count: number;
Mean: number;
L0: number;
L1: number;
L2: number;
L3: number;
constructor(data: Partial<Score>) {
this.Count = data?.Count ?? 0;
this.Mean = data?.Mean ?? 0;
this.L0 = data?.L0 ?? 0;
this.L1 = data?.L1 ?? 0;
this.L2 = data?.L2 ?? 0;
this.L3 = data?.L3 ?? 0;
}
}

View File

@@ -1,118 +1,210 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import Papa, { ParseRemoteConfig, ParseResult } from 'papaparse'; import Papa, { ParseRemoteConfig, ParseResult } from 'papaparse';
import { Milestone } from '../models/milestone'; import { ReplaySubject } from 'rxjs';
import { Column } from '../enums/column'; import { KeyValue } from '../models/key-value';
import { Subject } from 'rxjs'; import { Csv } from '../enums/csv';
import { Milestone } from '../enums/milestone';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class DataService { export class DataService {
private _data: Milestone[] = []; private _data: Record<string, any>[] = [];
private data = new ReplaySubject<Record<string, any>[]>();
readonly Data = this.data.asObservable();
private counties = new Subject<string[]>(); private _counties = new Map<string, string>();
private counties = new ReplaySubject<KeyValue[]>();
readonly Counties = this.counties.asObservable(); readonly Counties = this.counties.asObservable();
private schools = new Subject<string[]>(); private _schools = new Map<string, string>();
private schools = new ReplaySubject<KeyValue[]>();
readonly Schools = this.schools.asObservable(); readonly Schools = this.schools.asObservable();
private years = new Subject<number[]>(); private _years = Array(2023 - 2015 + 1)
.fill(0)
.map((_, index) => 2015 + index)
.filter((y) => y !== 2020);
private years = new ReplaySubject<KeyValue[]>();
readonly Years = this.years.asObservable(); readonly Years = this.years.asObservable();
private grades = new Subject<number[]>(); private _grades = Array(8 - 3 + 1)
.fill(0)
.map((_, index) => 3 + index);
private grades = new ReplaySubject<KeyValue[]>();
readonly Grades = this.grades.asObservable(); readonly Grades = this.grades.asObservable();
private _cohorts = new Map<string, string>();
private cohorts = new ReplaySubject<KeyValue[]>();
readonly Cohorts = this.cohorts.asObservable();
constructor() { constructor() {
this.load(); this.load();
} }
private load(): void { private load(): void {
let files: { url: string; year: number; grade: number }[] = []; let count = this._years.length * this._grades.length;
for (let year = 2015; year < 2024; ++year) { this._years.forEach((year: number) => {
if (year !== 2020) { this._grades.forEach((grade: number) => {
for (let grade = 3; grade < 9; ++grade) { Papa.parse(`assets/data/${year}-${grade}.csv`, {
files.push({ download: true,
url: `assets/data/${year}-${grade}.csv`, header: false,
year: year, complete: (results: ParseResult<Record<string, any>>) => {
grade: grade, const headers = this.getHeaders((results?.data ?? []).slice(1, 3));
}); const lookup = (x: string, record: Record<string, any>): any => {
} const idx = headers.findIndex((h: string) => x === h);
} return (idx > -1 ? record[idx] : '') as any;
} };
const cohort = year + 12 - grade;
this._cohorts.set(`${cohort}`, `Class of ${cohort}`);
let count = files.length; const data: Record<string, any>[] = [];
files.forEach((file: { url: string; year: number; grade: number }) => {
Papa.parse(file.url, { (results?.data ?? []).forEach((record: Record<string, any>) => {
download: true, let code = parseInt(lookup(Csv.County_Code, record));
header: false, // skip non-data rows on the csv
complete: (results: ParseResult<Record<string, any>>) => { if (isNaN(code)) return;
(results?.data ?? []).forEach(
(record: Record<string, any>, index: number) => { const ms = {} as Record<string, any>;
if (index < 3) return; // const ms = new Milestone({});
this._data.push( ms[Milestone.Year] = year;
new Milestone({ ms[Milestone.Grade] = grade;
Year: file.year, ms[Milestone.Cohort] = cohort;
Grade: file.grade, ms[Milestone.County] = lookup(Csv.County_Code, record);
County: record[Column.County], ms[Milestone.School] = lookup(Csv.School_Code, record);
School: record[Column.School], ms[Milestone.ELACount] = lookup(Csv.ELA_Count, record);
ELA: { ms[Milestone.ELAMean] = lookup(Csv.ELA_Mean, record);
Count: record[Column.ELA_Count], ms[Milestone.ELA0] = lookup(Csv.ELA_L0, record);
Mean: record[Column.ELA_Mean], ms[Milestone.ELA1] = lookup(Csv.ELA_L1, record);
L0: record[Column.ELA_0], ms[Milestone.ELA2] = lookup(Csv.ELA_L2, record);
L1: record[Column.ELA_1], ms[Milestone.ELA3] = lookup(Csv.ELA_L3, record);
L2: record[Column.ELA_2], ms[Milestone.MathCount] = lookup(Csv.Math_Count, record);
L3: record[Column.ELA_3], ms[Milestone.MathMean] = lookup(Csv.Math_Mean, record);
}, ms[Milestone.Math0] = lookup(Csv.Math_L0, record);
Math: { ms[Milestone.Math1] = lookup(Csv.Math_L1, record);
Count: record[Column.Math_Count], ms[Milestone.Math2] = lookup(Csv.Math_L2, record);
Mean: record[Column.Math_Mean], ms[Milestone.Math3] = lookup(Csv.Math_L3, record);
L0: record[Column.Math_0], ms[Milestone.SciCount] = lookup(Csv.Science_Count, record);
L1: record[Column.Math_1], ms[Milestone.SciMean] = lookup(Csv.Science_Mean, record);
L2: record[Column.Math_2], ms[Milestone.Sci0] = lookup(Csv.Science_L0, record);
L3: record[Column.Math_3], ms[Milestone.Sci1] = lookup(Csv.Science_L1, record);
}, ms[Milestone.Sci2] = lookup(Csv.Science_L2, record);
Science: { ms[Milestone.Sci3] = lookup(Csv.Science_L3, record);
Count: record[Column.Sci_Count], ms[Milestone.SocCount] = lookup(Csv.Social_Count, record);
Mean: record[Column.Sci_Mean], ms[Milestone.SocMean] = lookup(Csv.Social_Mean, record);
L0: record[Column.Sci_0], ms[Milestone.Soc0] = lookup(Csv.Social_L0, record);
L1: record[Column.Sci_1], ms[Milestone.Soc1] = lookup(Csv.Social_L1, record);
L2: record[Column.Sci_2], ms[Milestone.Soc2] = lookup(Csv.Social_L2, record);
L3: record[Column.Sci_3], ms[Milestone.Soc3] = lookup(Csv.Social_L3, record);
}, data.push(ms);
Social: {
Count: record[Column.Soc_Count], this._counties.set(
Mean: record[Column.Soc_Mean], lookup(Csv.County_Code, record),
L0: record[Column.Soc_0], lookup(Csv.County_Label, record)
L1: record[Column.Soc_1],
L2: record[Column.Soc_2],
L3: record[Column.Soc_3],
},
})
); );
} this._schools.set(
); `${lookup(Csv.County_Code, record)}-${lookup(
--count; Csv.School_Code,
if (count === 0) this.loadComplete(); record
}, )}`,
error: (error: any) => { lookup(Csv.School_Label, record)
console.error(error); );
--count; });
if (count === 0) this.loadComplete();
}, [
} as ParseRemoteConfig); { sort: Milestone.ELAMean, rank: Milestone.ELARank },
{ sort: Milestone.MathMean, rank: Milestone.MathRank },
{ sort: Milestone.SciMean, rank: Milestone.SciRank },
{ sort: Milestone.SocMean, rank: Milestone.SocRank },
].forEach((m) => {
data
.sort(
(a: Record<string, any>, b: Record<string, any>) =>
+a[m.sort] - b[m.sort]
)
.forEach(
(a: Record<string, any>, index: number) => (a[m.rank] = index)
);
});
this._data.push(...data);
--count;
if (count === 0) this.loadComplete();
},
error: (error: any) => {
console.error(error);
--count;
if (count === 0) this.loadComplete();
},
} as ParseRemoteConfig);
});
}); });
} }
private getHeaders(data: Record<string, any>[]): string[] {
const headers: string[] = [];
data.forEach((record: Record<string, any>) => {
let prv = '';
Object.keys(record).forEach((key: string, index: number) => {
let value = (record[key] ?? '')
.replace(/\n/g, '')
.replace(/- EOG/g, '');
if (value.length === 0) value = prv;
headers[index] = `${headers[index] ?? ''} ${value}`.trim();
prv = value;
});
});
return headers;
}
private loadComplete(): void { private loadComplete(): void {
this.cohorts.next(
[...this._cohorts]
.map(([key, value]) => new KeyValue({ key: key, value: value }))
.sort((a: KeyValue, b: KeyValue) => a.value.localeCompare(b.value))
);
this.counties.next( this.counties.next(
[...new Set(this._data.map((data: Milestone) => data.County))].sort() [...this._counties]
.map(
([code, label]) =>
new KeyValue({
key: code,
value: label
.toLowerCase()
.replace(/\b[a-z]/g, (c) => c.toUpperCase()),
})
)
.sort((a: KeyValue, b: KeyValue) => a.value.localeCompare(b.value))
); );
this.schools.next( this.schools.next(
[...new Set(this._data.map((data: Milestone) => data.School))].sort() [...this._schools]
.map(
([code, label]) =>
new KeyValue({
key: code,
value: label
.toLowerCase()
.replace(/\b[a-z]/g, (c) => c.toUpperCase()),
})
)
.sort((a: KeyValue, b: KeyValue) => a.value.localeCompare(b.value))
); );
this.years.next( this.years.next(
[...new Set(this._data.map((data: Milestone) => data.Year))].sort() this._years
.map(
(year: number) =>
new KeyValue({ key: year.toString(), value: year.toString() })
)
.sort((a: KeyValue, b: KeyValue) => a.value.localeCompare(b.value))
); );
this.grades.next( this.grades.next(
[...new Set(this._data.map((data: Milestone) => data.Grade))].sort() this._grades
.map(
(grade: number) =>
new KeyValue({ key: grade.toString(), value: grade.toString() })
)
.sort((a: KeyValue, b: KeyValue) => a.value.localeCompare(b.value))
); );
this.data.next(this._data);
} }
} }

View File

@@ -0,0 +1,25 @@
import { Injectable } from '@angular/core';
import { Subject } from 'rxjs';
import { KeyValue } from '../models/key-value';
@Injectable({ providedIn: 'root' })
export class FilterService {
private _values: KeyValue[] = [];
private set filters(value: KeyValue[]) {
this._values = value;
this.filterSubject.next(this._values);
}
private get filters(): KeyValue[] {
return this._values;
}
private filterSubject = new Subject<KeyValue[]>();
readonly filters$ = this.filterSubject.asObservable();
constructor() {}
set(key: string, value: any): void {
const update = this.filters.filter((f: KeyValue) => f.key !== key);
if (!!value) update.push(new KeyValue({ key: key, value: value }));
this.filters = update;
}
}