Compare commits

..

18 Commits

Author SHA1 Message Date
8adc9b1ea7 [main]: requirements fix 2024-12-02 22:28:39 +04:00
0aafeda77d Merge pull request 'floris_design' (#7) from floris_design into main
Reviewed-on: #7
2024-11-24 14:13:10 +04:00
93febb40d3 Merge remote-tracking branch 'origin' into floris_design 2024-11-24 14:11:34 +04:00
2d8465fb09 Merge pull request 'test-entity' (#6) from test-entity into main
Reviewed-on: #6
2024-11-24 13:45:05 +04:00
9ca0430fda [test-entity]: router fix 2024-11-24 12:55:59 +04:00
658a351d28 [test-entity]: front pt.2 2024-11-20 03:08:12 +04:00
a60304ca0f Merge branch 'test-entity' of https://git.is.ulstu.ru/ElEgEv/EvaluationEfficiencyOptimizationWind into test-entity 2024-11-20 00:06:41 +04:00
ee6bbdca8b [test-entity]: front 2024-11-20 00:06:19 +04:00
507115ece6 fix for Maxim 2024-11-19 23:07:28 +04:00
4d40a2cacb get_turbines_by_park_id route fix 2024-11-19 17:16:09 +04:00
6e87595e2f [test-entity]: front pt. 1 2024-11-11 23:56:59 +04:00
d387e5c5bf [test-entity]: front refresh 2024-11-11 00:55:10 +04:00
1fd0f946e6 Rest api, CRUD for wind_parks 2024-11-05 20:53:55 +04:00
Никита Сергеев
484f1f205e repository.py schemas.py wind_park_router.py 2024-11-05 20:13:37 +04:00
Никита Сергеев
65b90f9636 base entity 2024-11-05 19:48:50 +04:00
dee27b731b New type of plots 2024-11-05 02:04:46 +04:00
7cdbd65432 Yaw angles and fix 2024-11-04 02:24:15 +04:00
7db293de96 Add multiple charts for wind turbines 2024-11-04 01:59:43 +04:00
265 changed files with 4500 additions and 1206 deletions

View File

@ -1,10 +1,11 @@
from PyWeather.weather.stations.davis import VantagePro
import logging
import time
import mariadb
import serial.tools.list_ports
import gc
import time
from pprint import pprint
from PyWeather.weather.stations.davis import VantagePro
logging.basicConfig(filename="Stations.log",
format='%(asctime)s %(message)s',
@ -12,10 +13,37 @@ logging.basicConfig(filename="Stations.log",
logger = logging.getLogger('davis_api')
logger.setLevel(logging.DEBUG)
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.DEBUG)
console_handler.setFormatter(logging.Formatter('%(asctime)s %(message)s'))
logger.addHandler(console_handler)
def write_data(device, station, send=True):
try:
#device.parse()
data = device.fields
print(data)
if len(data) < 1:
return
else:
print(data)
fields = ['BarTrend', 'CRC', 'DateStamp', 'DewPoint', 'HeatIndex', 'ETDay', 'HeatIndex',
'HumIn', 'HumOut', 'Pressure', 'RainDay', 'RainMonth', 'RainRate', 'RainStorm',
'RainYear', 'SunRise', 'SunSet', 'TempIn', 'TempOut', 'WindDir', 'WindSpeed',
'WindSpeed10Min']
if send:
placeholders = ', '.join(['%s'] * len(fields))
field_names = ', '.join(fields)
sql = f"INSERT INTO weather_data ({field_names}) VALUES ({placeholders})"
values = [data[field] for field in fields]
cursor.execute(sql, values)
conn.commit()
else:
pprint(data)
del data
del fields
gc.collect()
except Exception as e:
logger.error(str(e))
raise e
try:
conn = mariadb.connect(
@ -29,7 +57,7 @@ try:
except mariadb.Error as e:
logger.error('DB_ERR: ' + str(e))
raise e
while True:
try:
ports = serial.tools.list_ports.comports()
available_ports = {}
@ -39,15 +67,13 @@ while True:
available_ports[port.name] = port.vid
devices = [VantagePro(port) for port in available_ports.keys()]
print(available_ports)
while True:
for i in range(1):
if len(devices) != 0:
logger.info(devices)
else:
raise Exception('Can`t connect to device')
time.sleep(60)
for i in range(len(devices)):
print(devices[i].fields)
#write_data(devices[i], 'st' + str(available_ports[list(available_ports.keys())[i]]), True)
time.sleep(1)
except Exception as e:
logger.error('Device_error' + str(e))
time.sleep(60)
logger.error('Device_error: ' + str(e))
raise e
# todo переписать под influx, для линухи приколы сделать

View File

@ -1,189 +0,0 @@
from datetime import datetime
from pathlib import Path
import metpy.calc
import numpy as np
import requests
import torch
import xarray as xr
from aurora import AuroraSmall, Batch, Metadata
from metpy.units import units
def get_download_paths(date):
"""Создает список путей для загрузки данных."""
download_path = Path("~/downloads/hres_0.1").expanduser()
downloads = {}
var_nums = {
"2t": "167", "10u": "165", "10v": "166", "msl": "151", "t": "130",
"u": "131", "v": "132", "q": "133", "z": "129", "slt": "043", "lsm": "172",
}
for v in ["2t", "10u", "10v", "msl", "z", "slt", "lsm"]:
downloads[download_path / date.strftime(f"surf_{v}_%Y-%m-%d.grib")] = (
f"https://data.rda.ucar.edu/ds113.1/"
f"ec.oper.an.sfc/{date.year}{date.month:02d}/ec.oper.an.sfc.128_{var_nums[v]}_{v}."
f"regn1280sc.{date.year}{date.month:02d}{date.day:02d}.grb"
)
for v in ["z", "t", "u", "v", "q"]:
for hour in [0, 6, 12, 18]:
prefix = "uv" if v in {"u", "v"} else "sc"
downloads[download_path / date.strftime(f"atmos_{v}_%Y-%m-%d_{hour:02d}.grib")] = (
f"https://data.rda.ucar.edu/ds113.1/"
f"ec.oper.an.pl/{date.year}{date.month:02d}/ec.oper.an.pl.128_{var_nums[v]}_{v}."
f"regn1280{prefix}.{date.year}{date.month:02d}{date.day:02d}{hour:02d}.grb"
)
return downloads, download_path
def download_data(downloads):
"""Скачивает файлы, если они отсутствуют в целевой директории."""
for target, source in downloads.items():
if not target.exists():
print(f"Downloading {source}")
target.parent.mkdir(parents=True, exist_ok=True)
response = requests.get(source)
response.raise_for_status()
with open(target, "wb") as f:
f.write(response.content)
print("Downloads finished!")
def load_surf(v, v_in_file, download_path, date):
"""Загружает переменные поверхностного уровня или статические переменные."""
ds = xr.open_dataset(download_path / date.strftime(f"surf_{v}_%Y-%m-%d.grib"), engine="cfgrib")
data = ds[v_in_file].values[:2]
data = data[None]
return torch.from_numpy(data)
def load_atmos(v, download_path, date, levels):
"""Загружает атмосферные переменные для заданных уровней давления."""
ds_00 = xr.open_dataset(
download_path / date.strftime(f"atmos_{v}_%Y-%m-%d_00.grib"), engine="cfgrib"
)
ds_06 = xr.open_dataset(
download_path / date.strftime(f"atmos_{v}_%Y-%m-%d_06.grib"), engine="cfgrib"
)
ds_00 = ds_00[v].sel(isobaricInhPa=list(levels))
ds_06 = ds_06[v].sel(isobaricInhPa=list(levels))
data = np.stack((ds_00.values, ds_06.values), axis=0)
data = data[None]
return torch.from_numpy(data)
def create_batch(date, levels, downloads, download_path):
"""Создает объект Batch с данными для модели."""
ds = xr.open_dataset(next(iter(downloads.keys())), engine="cfgrib")
batch = Batch(
surf_vars={
"2t": load_surf("2t", "t2m", download_path, date),
"10u": load_surf("10u", "u10", download_path, date),
"10v": load_surf("10v", "v10", download_path, date),
"msl": load_surf("msl", "msl", download_path, date),
},
static_vars={
"z": load_surf("z", "z", download_path, date)[0, 0],
"slt": load_surf("slt", "slt", download_path, date)[0, 0],
"lsm": load_surf("lsm", "lsm", download_path, date)[0, 0],
},
atmos_vars={
"t": load_atmos("t", download_path, date, levels),
"u": load_atmos("u", download_path, date, levels),
"v": load_atmos("v", download_path, date, levels),
"q": load_atmos("q", download_path, date, levels),
"z": load_atmos("z", download_path, date, levels),
},
metadata=Metadata(
lat=torch.from_numpy(ds.latitude.values),
lon=torch.from_numpy(ds.longitude.values),
time=(date.replace(hour=6),),
atmos_levels=levels,
),
)
return batch.regrid(res=0.1)
def create_batch_random(levels: tuple[int], date: tuple):
"""Создает объект Batch с рандомными данными для модели."""
return Batch(
surf_vars={k: torch.randn(1, 2, 17, 32) for k in ("2t", "10u", "10v", "msl")},
static_vars={k: torch.randn(17, 32) for k in ("lsm", "z", "slt")},
atmos_vars={k: torch.randn(1, 2, 4, 17, 32) for k in ("z", "u", "v", "t", "q")},
metadata=Metadata(
lat=torch.linspace(90, -90, 17),
lon=torch.linspace(0, 360, 32 + 1)[:-1],
time=date,
atmos_levels=levels,
),
)
def run_model(batch):
"""Инициализирует модель AuroraSmall и выполняет предсказание."""
model = AuroraSmall()
model.load_checkpoint("microsoft/aurora", "aurora-0.25-small-pretrained.ckpt")
model.eval()
model = model.to("cpu")
with torch.inference_mode():
prediction = model.forward(batch)
return prediction
def get_wind_speed_and_direction(prediction, batch: Batch, lat: float, lon: float):
target_lat = lat
target_lon = lon
lat_idx = torch.abs(batch.metadata.lat - target_lat).argmin()
lon_idx = torch.abs(batch.metadata.lon - target_lon).argmin()
u_values = prediction.atmos_vars["u"][:, :, :, lat_idx, lon_idx]
v_values = prediction.atmos_vars["v"][:, :, :, lat_idx, lon_idx]
wind_speeds=[]
wind_directions=[]
for i in range(u_values.numel()):
u_scalar = u_values.view(-1)[i].item() # Разворачиваем тензор в одномерный и берем элемент
v_scalar = v_values.view(-1)[i].item()
print("u value:", u_scalar)
print("v value:", v_scalar)
u_with_units = u_scalar * units("m/s")
v_with_units = v_scalar * units("m/s")
# Рассчитайте направление и скорость ветра
wind_dir = metpy.calc.wind_direction(u_with_units, v_with_units)
wind_speed = metpy.calc.wind_speed(u_with_units, v_with_units)
wind_speeds.append(wind_speed.magnitude.item())
wind_directions.append(wind_dir.magnitude.item())
return wind_speeds,wind_directions
def wind_direction_to_text(wind_dir_deg):
directions = [
"север", "северо-восток", "восток", "юго-восток",
"юг", "юго-запад", "запад", "северо-запад"
]
idx = int((wind_dir_deg + 22.5) // 45) % 8
return directions[idx]
def main():
levels = (100,)
date1 = datetime(2024, 11, 27, 12)
date2 = datetime(2024, 11, 28, 12)
date_tuple = (date1, date2,)
# downloads, download_path = get_download_paths(date)
# download_data(downloads) # Скачиваем данные, если их нет
# batch_actual = create_batch(date, levels, downloads, download_path)
batch_actual = create_batch_random(levels, date_tuple)
prediction_actual = run_model(batch_actual)
wind_speed_and_direction = get_wind_speed_and_direction(prediction_actual, batch_actual, 50, 20)
return wind_speed_and_direction
if __name__ == "__main__":
main()
print("Prediction completed!")

View File

@ -2811,7 +2811,7 @@
"version": "15.7.13",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.13.tgz",
"integrity": "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==",
"devOptional": true
"dev": true
},
"node_modules/@types/qs": {
"version": "6.9.16",
@ -2829,7 +2829,7 @@
"version": "18.3.8",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.8.tgz",
"integrity": "sha512-syBUrW3/XpnW4WJ41Pft+I+aPoDVbrBVQGEnbD7NijDGlVC+8gV/XKRY+7vMDlfPpbwYt0l1vd/Sj8bJGMbs9Q==",
"devOptional": true,
"dev": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
@ -4262,7 +4262,7 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"devOptional": true
"dev": true
},
"node_modules/debug": {
"version": "4.3.7",

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 4 6" style="enable-background:new 0 0 4 6;" xml:space="preserve">
<path d="M0.49,2.99c0-0.13,0.05-0.26,0.15-0.35l2-2c0.2-0.2,0.51-0.2,0.71,0s0.2,0.51,0,0.71L1.71,2.99l1.65,1.65
c0.2,0.2,0.2,0.51,0,0.71s-0.51,0.2-0.71,0l-2-2C0.54,3.25,0.49,3.12,0.49,2.99z"/>
</svg>

After

Width:  |  Height:  |  Size: 548 B

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 4 6" style="enable-background:new 0 0 4 6;" xml:space="preserve">
<path d="M3.51,3.01c0,0.13-0.05,0.26-0.15,0.35l-2,2c-0.2,0.2-0.51,0.2-0.71,0c-0.2-0.2-0.2-0.51,0-0.71l1.64-1.64L0.64,1.36
c-0.2-0.2-0.2-0.51,0-0.71s0.51-0.2,0.71,0l2,2C3.46,2.75,3.51,2.88,3.51,3.01z"/>
</svg>

After

Width:  |  Height:  |  Size: 558 B

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 6 6" style="enable-background:new 0 0 6 6;" xml:space="preserve">
<path d="M1.23,5.47L3,3.71l1.77,1.77c0.2,0.2,0.51,0.2,0.71,0c0.2-0.2,0.2-0.51,0-0.71L3.71,3l1.77-1.77c0.2-0.2,0.2-0.51,0-0.71
c-0.2-0.2-0.51-0.2-0.71,0L3,2.29L1.23,0.53c-0.2-0.2-0.51-0.2-0.71,0s-0.2,0.51,0,0.71L2.29,3L0.53,4.77c-0.2,0.2-0.2,0.51,0,0.71
C0.72,5.67,1.04,5.67,1.23,5.47z"/>
</svg>

After

Width:  |  Height:  |  Size: 646 B

View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 6 6" style="enable-background:new 0 0 6 6;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
</style>
<g>
<line class="st0" x1="0.5" y1="1" x2="5.5" y2="1"/>
<path d="M5.5,1.5h-5C0.22,1.5,0,1.28,0,1s0.22-0.5,0.5-0.5h5C5.78,0.5,6,0.72,6,1S5.78,1.5,5.5,1.5z"/>
</g>
<g>
<line class="st0" x1="0.5" y1="3" x2="5.5" y2="3"/>
<path d="M5.5,3.5h-5C0.22,3.5,0,3.28,0,3s0.22-0.5,0.5-0.5h5C5.78,2.5,6,2.72,6,3S5.78,3.5,5.5,3.5z"/>
</g>
<g>
<line class="st0" x1="0.5" y1="5" x2="5.5" y2="5"/>
<path d="M5.5,5.5h-5C0.22,5.5,0,5.28,0,5s0.22-0.5,0.5-0.5h5C5.78,4.5,6,4.72,6,5S5.78,5.5,5.5,5.5z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 914 B

View File

@ -1,7 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 13 13" style="enable-background:new 0 0 13 13;" xml:space="preserve">
<path d="M9.5,6H7V3.5C7,3.22,6.78,3,6.5,3S6,3.22,6,3.5V6H3.5C3.22,6,3,6.22,3,6.5S3.22,7,3.5,7H6v2.5C6,9.78,6.22,10,6.5,10
S7,9.78,7,9.5V7h2.5C9.78,7,10,6.78,10,6.5S9.78,6,9.5,6z"/>
</svg>

Before

Width:  |  Height:  |  Size: 541 B

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 15 13" style="enable-background:new 0 0 15 13;" xml:space="preserve">
<g>
<path d="M7.85,7.08C7.81,7.04,7.75,7,7.69,6.98c-0.12-0.05-0.26-0.05-0.38,0C7.25,7,7.19,7.04,7.15,7.08l-2.5,2.5
c-0.2,0.2-0.2,0.51,0,0.71s0.51,0.2,0.71,0L7,8.64v3.79c0,0.28,0.22,0.5,0.5,0.5S8,12.71,8,12.44V8.64l1.65,1.65
c0.1,0.1,0.23,0.15,0.35,0.15s0.26-0.05,0.35-0.15c0.2-0.2,0.2-0.51,0-0.71L7.85,7.08z"/>
<path d="M9.38,0.06c-2.6,0-4.83,1.85-5.36,4.38H3.44c-1.83,0-3.31,1.49-3.31,3.31c0,1.01,0.45,1.95,1.23,2.58
c0.27,0.22,0.57,0.39,0.89,0.51c0.06,0.02,0.12,0.03,0.18,0.03c0.2,0,0.39-0.12,0.47-0.32c0.1-0.26-0.03-0.55-0.29-0.65
C2.38,9.82,2.17,9.7,1.99,9.55c-0.55-0.44-0.86-1.1-0.86-1.8c0-1.27,1.04-2.31,2.31-2.31h1c0.25,0,0.46-0.19,0.5-0.44
c0.28-2.24,2.19-3.94,4.44-3.94c2.48,0,4.5,2.02,4.5,4.5c0,1.29-0.56,2.53-1.53,3.38c-0.21,0.18-0.23,0.5-0.05,0.71
c0.18,0.21,0.5,0.23,0.71,0.05c1.19-1.04,1.87-2.55,1.87-4.13C14.88,2.53,12.41,0.06,9.38,0.06z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

51
front/src/api/api.ts Normal file
View File

@ -0,0 +1,51 @@
import { BASE_URL } from './constants';
import { ApiResponse } from './types';
import { unpack } from './utils';
const send = async <T>(
url: string,
init: RequestInit,
): Promise<ApiResponse<T>> => {
const fullURL = `${BASE_URL}/${url}`;
const fullInit: RequestInit = { ...init };
try {
const response = await fetch(fullURL, fullInit);
if (!response.ok) {
return {
data: null,
error: { status: response.status, message: 'Something went wrong' },
};
}
const raw = await response.json();
const data: T = unpack(raw);
return { data: data, error: null };
} catch {
return {
data: null,
error: { status: 0, message: 'Something went wrong' },
};
}
};
export const api = {
get: async <T>(url: string) => {
return send<T>(url, { method: 'GET' });
},
post: async <T>(url: string, body: unknown) => {
return send<T>(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
},
put: async <T>(url: string, body: unknown) => {
return send<T>(url, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
},
delete: async <T>(url: string) => {
return send<T>(url, { method: 'DELETE' });
},
};

View File

@ -0,0 +1 @@
export const BASE_URL = 'http://localhost:8000';

View File

@ -1,2 +0,0 @@
// export const BASE_URL = 'http://localhost:8000/api';
export const BASE_URL = 'http://192.168.1.110:8000/api';

View File

@ -1 +0,0 @@
export { downloadImage, getWindmillData } from './service';

View File

@ -1,26 +0,0 @@
import { WindmillFormStore } from '@components/ux/windmill-form';
import { BASE_URL } from './constants';
import { GetWindmillDataRes } from './types';
import { getWindmillDataParams } from './utils';
export const getWindmillData = async (store: Partial<WindmillFormStore>) => {
const params = getWindmillDataParams(store);
const url = `${BASE_URL}/floris/get_windmill_data?${params}`;
const init: RequestInit = {
method: 'GET',
};
const res: Response = await fetch(url, init);
const data: GetWindmillDataRes = await res.json();
return data;
};
export const downloadImage = async (imageName: string) => {
const url = `${BASE_URL}/floris/download_image/${imageName}`;
const init: RequestInit = {
method: 'GET',
};
const res: Response = await fetch(url, init);
const data = await res.blob();
return data;
};

View File

@ -1,4 +0,0 @@
export type GetWindmillDataRes = {
file_name: string;
data: number[];
};

View File

@ -1,9 +0,0 @@
import { WindmillFormStore } from '@components/ux/windmill-form';
export const getWindmillDataParams = (store: Partial<WindmillFormStore>) => {
const layoutX = store.windmills?.map((row) => `layout_x=${row.x}`).join('&');
const layoutY = store.windmills?.map((row) => `layout_y=${row.y}`).join('&');
const dateStart = `date_start=${store.dateFrom?.substring(0, 10)}`;
const dateEnd = `date_end=${store.dateTo?.substring(0, 10)}`;
return `${layoutX}&${layoutY}&${dateStart}&${dateEnd}`;
};

View File

@ -1 +0,0 @@
export * from './floris';

9
front/src/api/types.ts Normal file
View File

@ -0,0 +1,9 @@
export type ApiError = {
status: number;
message: string;
};
export type ApiResponse<T> = {
data: T | null;
error: ApiError | null;
};

23
front/src/api/utils.ts Normal file
View File

@ -0,0 +1,23 @@
export const toCamelCase = (str: string) => {
return str
.split(/[_\s-]+|(?=[A-Z])/)
.map((word, index) =>
index === 0
? word.toLowerCase()
: word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(),
)
.join('');
};
export const unpack = (obj: unknown) => {
if (Array.isArray(obj)) {
return obj.map((item) => unpack(item));
} else if (obj !== null && typeof obj === 'object') {
return Object.entries(obj).reduce((acc, [key, value]) => {
const newKey = toCamelCase(key);
acc[newKey] = unpack(value);
return acc;
}, {});
}
return obj;
};

View File

@ -0,0 +1,7 @@
export const WIND_ENDPOINTS = {
turbines: 'api/wind/turbines',
turbineType: 'api/wind/turbine_type',
parks: 'api/wind/parks',
park: 'api/wind/park',
parkTurbine: 'api/wind/park_turbine',
};

View File

@ -0,0 +1,2 @@
export * from './service';
export * from './types';

View File

@ -0,0 +1,117 @@
import { ApiResponse } from '@api/types';
import { ParkFormValues } from '@components/pages/park-page/types';
import { TurbineTypeFormValues } from '@components/pages/turbine-type-page/types';
import { api } from '../api';
import { WIND_ENDPOINTS } from './constants';
import { Park, ParkTurbine, ParkWithTurbines, TurbineType } from './types';
import { packPark, packParkTurbine, packTurbineTypes } from './utils';
export const getTurbineTypes = () => {
return api.get<TurbineType[]>(WIND_ENDPOINTS.turbines);
};
export const getTurbineType = (id: string) => {
const url = `${WIND_ENDPOINTS.turbineType}/${id}`;
return api.get<TurbineType>(url);
};
export const createTurbineTypes = (
formValues: Partial<TurbineTypeFormValues>,
) => {
return api.post<TurbineType>(
WIND_ENDPOINTS.turbineType,
packTurbineTypes(formValues),
);
};
export const editTurbineTypes = (
formValues: Partial<TurbineTypeFormValues>,
id: string,
) => {
const url = `${WIND_ENDPOINTS.turbineType}/${id}`;
return api.put<TurbineType>(url, packTurbineTypes(formValues));
};
export const deleteTurbineType = (id: number) => {
const url = `${WIND_ENDPOINTS.turbineType}/${id}`;
return api.delete(url);
};
export const getParks = () => {
return api.get<Park[]>(WIND_ENDPOINTS.parks);
};
export const getPark = (id: string) => {
const url = `${WIND_ENDPOINTS.park}/${id}`;
return api.get<Park>(url);
};
export const getParkTurbines = (id: string) => {
const url = `${WIND_ENDPOINTS.parks}/${id}/turbines`;
return api.get<ParkTurbine[]>(url);
};
export const getParkWithTurbines = async (
id: string,
): Promise<ApiResponse<ParkWithTurbines>> => {
const parkURL = `${WIND_ENDPOINTS.park}/${id}`;
const turbinesURL = `${WIND_ENDPOINTS.parks}/${id}/turbines`;
const parkPesponse = await api.get<Park>(parkURL);
const turbinesResponse = await api.get<ParkTurbine[]>(turbinesURL);
return {
data: { ...parkPesponse.data, turbines: turbinesResponse.data },
error: parkPesponse.error || turbinesResponse.error || null,
};
};
export const createPark = async (formValues: Partial<ParkFormValues>) => {
const parkPesponse = await api.post<Park>(
WIND_ENDPOINTS.park,
packPark(formValues),
);
await Promise.all(
formValues.turbines?.map((t) => {
return api.post(
WIND_ENDPOINTS.parkTurbine,
packParkTurbine(t, parkPesponse.data.id),
);
}),
);
return getParkWithTurbines(String(parkPesponse.data.id));
};
export const updatePark = async (
formValues: Partial<ParkFormValues>,
id: string,
) => {
const parkPesponse = await api.put<Park>(
`${WIND_ENDPOINTS.park}/${id}`,
packPark(formValues),
);
await Promise.all(
formValues.turbines?.map((t) => {
if (t.new) {
return api.post(
WIND_ENDPOINTS.parkTurbine,
packParkTurbine(t, parkPesponse.data.id),
);
}
if (t.delete) {
return api.delete(
`${WIND_ENDPOINTS.parkTurbine}/${parkPesponse.data.id}/${t.id}`,
);
}
return api.put(
`${WIND_ENDPOINTS.parkTurbine}/${parkPesponse.data.id}/${t.id}`,
packParkTurbine(t, parkPesponse.data.id),
);
}),
);
return getParkWithTurbines(id);
};
export const deletePark = (id: number) => {
const url = `${WIND_ENDPOINTS.park}/${id}`;
return api.delete(url);
};

View File

@ -0,0 +1,32 @@
export type TurbineType = {
id: number;
name: string;
height: number;
bladeLength: number;
};
export type Park = {
id: number;
name: string;
centerLatitude: number;
centerLongitude: number;
};
export type ParkTurbine = {
id: number;
name: string;
height: number;
bladeLength: number;
xOffset: number;
yOffset: number;
angle: number;
comment: string;
};
export type ParkWithTurbines = {
id: number;
name: string;
centerLatitude: number;
centerLongitude: number;
turbines: ParkTurbine[];
};

View File

@ -0,0 +1,32 @@
import {
ParkFormTurbine,
ParkFormValues,
} from '@components/pages/park-page/types';
import { TurbineTypeFormValues } from '@components/pages/turbine-type-page/types';
export const packTurbineTypes = (values: Partial<TurbineTypeFormValues>) => {
return {
Name: values.name ?? '',
Height: parseInt(values.height || '0'),
BladeLength: parseInt(values.bladeLength || '0'),
};
};
export const packPark = (values: Partial<ParkFormValues>) => {
return {
Name: values.name ?? '',
CenterLatitude: parseInt(values.centerLatitude || '0'),
CenterLongitude: parseInt(values.centerLongitude || '0'),
};
};
export const packParkTurbine = (turbine: ParkFormTurbine, parkId: number) => {
return {
wind_park_id: parkId,
turbine_id: turbine.id,
x_offset: parseInt(turbine.xOffset || '0'),
y_offset: parseInt(turbine.yOffset || '0'),
angle: parseInt(turbine.angle || '0'),
comment: turbine.comment ?? '',
};
};

View File

@ -0,0 +1,19 @@
@function scale($values, $factor) {
@if type-of($values) == 'list' {
$m-values: ();
@each $value in $values {
$m-values: append($m-values, $value * $factor);
}
@return $m-values;
} @else {
@return nth($values, 1) * $factor;
}
}
@function m($values) {
@return scale($values, 1.25);
}
@function l($values) {
@return scale($values, 1.5);
}

View File

@ -0,0 +1,5 @@
@mixin on-mobile {
@media (width <= 800px) {
@content;
}
}

View File

@ -2,14 +2,12 @@
color-scheme: light;
--clr-primary: #4176FF;
--clr-primary-o50: #3865DA80;
--clr-primary-o50: #4176FF80;
--clr-primary-hover: #638FFF;
--clr-primary-active: #3D68D7;
--clr-on-primary: #FFFFFF;
--clr-secondary: #EAEAEA;
--clr-secondary-hover: #EFEFEF;
--clr-secondary-active: #E1E1E1;
--clr-secondary: #E1EAF8;
--clr-secondary-hover: #E8ECF0;
--clr-on-secondary: #0D0D0D;
--clr-layer-100: #EBEEF0;
@ -20,6 +18,7 @@
--clr-text-100: #8D8D8D;
--clr-text-200: #6C7480;
--clr-text-300: #1D1F20;
--clr-text-primary: #3865DA;
--clr-border-100: #DFDFDF;
--clr-border-200: #D8D8D8;
@ -28,6 +27,8 @@
--clr-shadow-200: #00000026;
--clr-ripple: #1D1F2026;
--clr-error: #E54B4B;
}
@mixin dark {
@ -36,12 +37,12 @@
--clr-primary: #3865DA;
--clr-primary-o50: #3865DA80;
--clr-primary-hover: #4073F7;
--clr-primary-active: #2A4DA7;
--clr-primary-disabled: #334570;
--clr-on-primary: #FFFFFF;
--clr-secondary: #3F3F3F;
--clr-secondary-hover: #4D4D4D;
--clr-secondary-active: #323232;
--clr-secondary-disabled: #323232;
--clr-on-secondary: #FFFFFF;
--clr-layer-100: #1B1B1B;
@ -52,6 +53,7 @@
--clr-text-100: #888888;
--clr-text-200: #C5C5C5;
--clr-text-300: #F0F0F0;
--clr-text-primary: #4176FF;
--clr-border-100: #3D3D3D;
--clr-border-200: #545454;
@ -60,4 +62,6 @@
--clr-shadow-200: #00000026;
--clr-ripple: #F0F0F026;
--clr-error: #FF6363;
}

View File

@ -0,0 +1,35 @@
import './styles.scss';
import '@public/fonts/styles.css';
import { MainLayout } from '@components/layouts';
import {
ParkPage,
ParksPage,
TurbineTypePage,
TurbineTypesPage,
} from '@components/pages';
import { ROUTES } from '@utils/route';
import React from 'react';
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
export function App() {
return (
<BrowserRouter>
<Routes>
<Route element={<MainLayout />}>
<Route
path={ROUTES.turbineTypes.path}
element={<TurbineTypesPage />}
/>
<Route path={ROUTES.turbineType.path} element={<TurbineTypePage />} />
<Route path={ROUTES.parks.path} element={<ParksPage />} />
<Route path={ROUTES.park.path} element={<ParkPage />} />
</Route>
<Route
path="*"
element={<Navigate to={ROUTES.turbineTypes.path} replace />}
/>
</Routes>
</BrowserRouter>
);
}

View File

@ -0,0 +1 @@
export { App } from './component';

View File

@ -1,21 +0,0 @@
import './styles.scss';
import '@public/fonts/styles.css';
import { MainLayout } from '@components/layouts';
import { HomePage } from '@components/pages';
import React from 'react';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
function App() {
return (
<BrowserRouter>
<Routes>
<Route element={<MainLayout />}>
<Route path={'/'} element={<HomePage />} />
</Route>
</Routes>
</BrowserRouter>
);
}
export default App;

View File

@ -23,7 +23,7 @@ html[data-theme='default'] {
}
html {
--td-100: 0.2s;
--td-100: 0.1s;
}
body {

View File

@ -0,0 +1 @@
export { MainLayout } from './main-layout';

View File

@ -1,3 +0,0 @@
import MainLayout from './main-layout';
export { MainLayout };

View File

@ -0,0 +1,22 @@
import { Header, Sidebar } from '@components/ux';
import { useDeviceType } from '@utils/device';
import React from 'react';
import { Outlet } from 'react-router-dom';
import styles from './styles.module.scss';
export function MainLayout() {
const deviceType = useDeviceType();
return (
<div className={styles.mainLayout}>
{deviceType === 'mobile' ? (
<Header className={styles.header} />
) : (
<Sidebar className={styles.sidebar} />
)}
<main className={styles.main}>
<Outlet />
</main>
</div>
);
}

View File

@ -1,18 +1 @@
import { Header } from '@components/ux';
import React from 'react';
import { Outlet } from 'react-router-dom';
import styles from './styles.module.scss';
function MainLayout() {
return (
<div className={styles.mainLayout}>
<Header />
<main className={styles.main}>
<Outlet />
</main>
</div>
);
}
export default MainLayout;
export * from './component';

View File

@ -1,13 +1,38 @@
@use '@components/mixins.scss' as m;
.mainLayout {
display: grid;
height: 100%;
grid-template:
'sidebar main' minmax(0, 1fr)
/ auto minmax(0, 1fr);
}
.sidebar {
grid-area: sidebar;
}
.header {
grid-area: header;
}
.main {
display: grid;
overflow: auto;
height: 100%;
grid-area: main;
grid-template-columns: 1fr minmax(0, 1000px) 1fr;
& > * {
grid-column: 2;
}
}
@include m.on-mobile {
.mainLayout {
grid-template:
'header' auto
'main' minmax(0, 1fr)
/ minmax(0, 1fr);
}
.main {
overflow: auto;
height: 100%;
}

View File

@ -1,41 +0,0 @@
import { Heading, Paragraph } from '@components/ui';
import { WindmillForm } from '@components/ux';
import { WindmillFormResponse } from '@components/ux/windmill-form';
import React, { useState } from 'react';
import styles from './styles.module.scss';
export function HomePage() {
const [result, setResult] = useState<WindmillFormResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const handleFormSuccess = (response: WindmillFormResponse) => {
setResult(response);
setError(null);
};
const handleFormFail = (message: string) => {
setError(message);
setResult(null);
};
return (
<div className={styles.page}>
<div className={styles.wrapperForm}>
<WindmillForm onSuccess={handleFormSuccess} onFail={handleFormFail} />
</div>
<div className={styles.result}>
<Heading tag="h3">Result</Heading>
{result && (
<>
<div className={styles.power}>{result.power.join(' ')}</div>
<div className={styles.image}>
{result.image && <img src={result.image} alt="Image" />}
</div>
</>
)}
{error && <Paragraph>{error}</Paragraph>}
</div>
</div>
);
}

View File

@ -1 +0,0 @@
export { HomePage } from './component';

View File

@ -1,41 +0,0 @@
.page {
display: grid;
padding: 20px;
gap: 20px;
grid-template:
'. form result .' auto
/ auto minmax(0, 380px) minmax(0, 700px) auto;
}
.wrapperForm {
grid-area: form;
}
.result {
display: flex;
flex-direction: column;
padding: 20px;
border-radius: 10px;
background-color: var(--clr-layer-200);
box-shadow: 0px 1px 2px var(--clr-shadow-100);
gap: 20px;
grid-area: result;
}
.image {
width: 100%;
img {
max-width: 100%;
border-radius: 10px;
}
}
@media (width <= 1000px) {
.page {
grid-template:
'form' auto
'result' auto
/ 1fr;
}
}

View File

@ -0,0 +1,4 @@
export * from './park-page';
export * from './parks-page';
export * from './turbine-type-page';
export * from './turbine-types-page';

View File

@ -1 +0,0 @@
export { HomePage } from './home-page';

View File

@ -0,0 +1,128 @@
import {
createPark,
getParkWithTurbines,
ParkWithTurbines,
updatePark,
} from '@api/wind';
import {
Button,
Dialog,
Heading,
NumberField,
TextInput,
} from '@components/ui';
import { ParkTurbines } from '@components/ux';
import { Controller, useForm } from '@utils/form';
import { ROUTES, useRoute } from '@utils/route';
import React, { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import styles from './styles.module.scss';
import { ParkFormValues } from './types';
import { unpackPark } from './utils';
export function ParkPage() {
const [park, setPark] = useState<ParkWithTurbines>(null);
const [pending, setPending] = useState<boolean>(false);
const [error, setError] = useState<string>(null);
const params = useParams();
const navigate = useNavigate();
const route = useRoute();
const { register, control, getValues, reset } = useForm<ParkFormValues>({});
const { id } = params;
const isEdit = id !== 'new';
const heading = isEdit ? 'Edit' : 'Create new';
const fetchPark = async () => {
const response = await getParkWithTurbines(id);
setPark(response.data);
reset(unpackPark(response.data));
};
useEffect(() => {
if (!isEdit) {
return;
}
fetchPark();
}, [id]);
const handleFormSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setPending(true);
if (isEdit) {
const { data, error } = await updatePark(getValues(), id);
if (error) {
setError('Something went wrong');
return;
}
setPark(data);
reset(unpackPark(data));
} else {
const { data, error } = await createPark(getValues());
if (error) {
setError('Something went wrong');
return;
}
navigate(ROUTES.park.path.replace(':id', String(data.id)));
}
setPending(false);
};
const handleReset = () => {
if (isEdit) {
reset(unpackPark(park));
} else {
reset({});
}
};
return (
<div className={styles.page} onSubmit={handleFormSubmit}>
<Heading tag="h1">{route.title}</Heading>
<form className={styles.form}>
<header>
<Heading tag="h3">{heading}</Heading>
</header>
<TextInput {...register('name')} label={{ text: 'Name' }} />
<div className={styles.inputBox}>
<Controller
{...control('centerLatitude')}
render={(props) => (
<NumberField label={{ text: 'Center Latitude' }} {...props} />
)}
/>
<Controller
{...control('centerLongitude')}
render={(props) => (
<NumberField label={{ text: 'Center Longitude' }} {...props} />
)}
/>
</div>
<div className={styles.buttonBox}>
<Button variant="secondary" onClick={handleReset}>
Reset
</Button>
<Button type="submit" pending={pending}>
Submit
</Button>
</div>
</form>
<Controller
{...control('turbines')}
render={(props) => (
<ParkTurbines savedTurbines={park?.turbines ?? []} {...props} />
)}
/>
<Dialog
open={Boolean(error)}
heading="Error"
message="Something went wrong"
onClose={() => setError(null)}
>
<Button onClick={() => setError(null)}>Ok</Button>
</Dialog>
</div>
);
}

View File

@ -1,23 +1,28 @@
.page {
display: grid;
padding: 40px 20px;
gap: 20px;
grid-template-rows: auto auto 1fr;
}
.form {
display: grid;
padding: 20px;
border-radius: 10px;
border-radius: 15px;
background-color: var(--clr-layer-200);
box-shadow: 0px 1px 2px var(--clr-shadow-100);
gap: 20px;
& > * {
width: 100%;
}
}
.dateRangeBox {
display: flex;
.inputBox {
display: grid;
gap: 10px;
grid-template-columns: 1fr 1fr;
}
.buttonBox {
display: flex;
justify-content: end;
padding-top: 20px;
gap: 10px;
}

View File

@ -0,0 +1,17 @@
export type ParkFormTurbine = {
id: number;
name: string;
xOffset: string;
yOffset: string;
angle: string;
comment: string;
new?: boolean;
delete?: boolean;
};
export type ParkFormValues = {
name: string;
centerLatitude: string;
centerLongitude: string;
turbines: ParkFormTurbine[];
};

View File

@ -0,0 +1,19 @@
import { ParkWithTurbines } from '@api/wind';
import { ParkFormValues } from './types';
export const unpackPark = (park: ParkWithTurbines): ParkFormValues => {
return {
name: park.name,
centerLatitude: String(park.centerLatitude),
centerLongitude: String(park.centerLongitude),
turbines: park.turbines.map((t) => ({
id: t.id,
name: t.name,
xOffset: String(t.xOffset),
yOffset: String(t.yOffset),
angle: String(t.angle),
comment: t.comment,
})),
};
};

View File

@ -0,0 +1,64 @@
import { deletePark, getParks, Park } from '@api/wind';
import { Button, Heading } from '@components/ui';
import { DataGrid } from '@components/ui/data-grid';
import { ROUTES, useRoute } from '@utils/route';
import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { columns } from './constants';
import styles from './styles.module.scss';
export function ParksPage() {
const [parks, setParks] = useState<Park[]>([]);
const [selected, setSelected] = useState<Park>(null);
const route = useRoute();
const fetchParks = async () => {
const res = await getParks();
setParks(res.data);
};
useEffect(() => {
fetchParks();
}, []);
const handleParkSelect = (items: Park[]) => {
setSelected(items[0] ?? null);
};
const handleDeleteButtonClick = async () => {
await deletePark(selected.id);
fetchParks();
};
return (
<div className={styles.page}>
<Heading tag="h1">{route.title}</Heading>
<div className={styles.actions}>
<Link to={ROUTES.park.path.replace(':id', 'new')}>
<Button>Create new</Button>
</Link>
{selected && (
<Link to={ROUTES.park.path.replace(':id', String(selected.id))}>
<Button variant="secondary">Edit</Button>
</Link>
)}
{selected && (
<Button variant="secondary" onClick={handleDeleteButtonClick}>
Delete
</Button>
)}
</div>
<div className={styles.dataGridWrapper}>
<DataGrid
items={parks}
columns={columns}
getItemKey={({ id }) => String(id)}
selectedItems={selected ? [selected] : []}
onItemsSelect={handleParkSelect}
multiselect={false}
/>
</div>
</div>
);
}

View File

@ -0,0 +1,8 @@
import { DataGridColumnConfig } from '@components/ui/data-grid/types';
import { Park } from 'src/api/wind';
export const columns: DataGridColumnConfig<Park>[] = [
{ name: 'Name', getText: (t) => t.name, width: 2 },
{ name: 'Center Latitude', getText: (t) => String(t.centerLatitude) },
{ name: 'Center Longitude', getText: (t) => String(t.centerLongitude) },
];

View File

@ -0,0 +1,19 @@
.page {
display: grid;
padding: 40px 20px;
gap: 20px;
grid-template-rows: auto auto minmax(0, 1fr);
}
.dataGridWrapper {
overflow: auto;
}
.actions {
display: flex;
padding: 10px;
border-radius: 15px;
background-color: var(--clr-layer-200);
box-shadow: 0px 1px 2px var(--clr-shadow-100);
gap: 10px;
}

View File

@ -0,0 +1,101 @@
import {
createTurbineTypes,
editTurbineTypes,
getTurbineType,
TurbineType,
} from '@api/wind';
import { Button, Heading, NumberField, TextInput } from '@components/ui';
import { Controller, useForm } from '@utils/form';
import { ROUTES, useRoute } from '@utils/route';
import React, { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import styles from './styles.module.scss';
import { TurbineTypeFormValues } from './types';
import { unpackTurbineType } from './utils';
export function TurbineTypePage() {
const [turbineType, setTurbineType] = useState<TurbineType>(null);
const [pending, setPending] = useState<boolean>(false);
const params = useParams();
const navigate = useNavigate();
const route = useRoute();
const { register, control, getValues, reset } =
useForm<TurbineTypeFormValues>({});
const { id } = params;
const isEdit = id !== 'new';
const heading = isEdit ? 'Edit' : 'Create new';
const fetchTurbineType = async () => {
const response = await getTurbineType(id);
setTurbineType(response.data);
reset(unpackTurbineType(response.data));
};
useEffect(() => {
if (!isEdit) {
return;
}
fetchTurbineType();
}, [id]);
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setPending(true);
if (isEdit) {
const response = await editTurbineTypes(getValues(), id);
setTurbineType(response.data);
reset(unpackTurbineType(response.data));
} else {
const response = await createTurbineTypes(getValues());
navigate(
ROUTES.turbineType.path.replace(':id', String(response.data.id)),
);
}
setPending(false);
};
const handleReset = () => {
if (isEdit) {
reset(unpackTurbineType(turbineType));
} else {
reset({});
}
};
return (
<div className={styles.page} onSubmit={handleSubmit}>
<Heading tag="h1">{route.title}</Heading>
<form className={styles.form}>
<header>
<Heading tag="h3">{heading}</Heading>
</header>
<TextInput {...register('name')} label={{ text: 'Name' }} />
<div className={styles.inputBox}>
<Controller
{...control('height')}
render={(props) => (
<NumberField label={{ text: 'Height' }} {...props} />
)}
/>
<Controller
{...control('bladeLength')}
render={(props) => (
<NumberField label={{ text: 'Blade length' }} {...props} />
)}
/>
</div>
<div className={styles.buttonBox}>
<Button variant="secondary" onClick={handleReset}>
Reset
</Button>
<Button type="submit" pending={pending}>
Submit
</Button>
</div>
</form>
</div>
);
}

View File

@ -0,0 +1 @@
export * from './component';

View File

@ -0,0 +1,28 @@
.page {
display: grid;
padding: 40px 20px;
gap: 20px;
grid-template-rows: auto auto 1fr;
}
.form {
display: grid;
padding: 20px;
border-radius: 15px;
background-color: var(--clr-layer-200);
box-shadow: 0px 1px 2px var(--clr-shadow-100);
gap: 20px;
}
.inputBox {
display: grid;
gap: 10px;
grid-template-columns: 1fr 1fr;
}
.buttonBox {
display: flex;
justify-content: end;
padding-top: 20px;
gap: 10px;
}

View File

@ -0,0 +1,5 @@
export type TurbineTypeFormValues = {
name: string;
height: string;
bladeLength: string;
};

View File

@ -0,0 +1,13 @@
import { TurbineType } from '@api/wind';
import { TurbineTypeFormValues } from './types';
export const unpackTurbineType = (
turbineType: TurbineType,
): TurbineTypeFormValues => {
return {
name: turbineType.name,
height: String(turbineType.height),
bladeLength: String(turbineType.bladeLength),
};
};

View File

@ -0,0 +1,66 @@
import { deleteTurbineType, getTurbineTypes, TurbineType } from '@api/wind';
import { Button, Heading } from '@components/ui';
import { DataGrid } from '@components/ui/data-grid';
import { ROUTES, useRoute } from '@utils/route';
import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { columns } from './constants';
import styles from './styles.module.scss';
export function TurbineTypesPage() {
const [turbineTypes, setTurbineTypes] = useState<TurbineType[]>([]);
const [selected, setSelected] = useState<TurbineType>(null);
const route = useRoute();
const fetchTurbineTypes = async () => {
const res = await getTurbineTypes();
setTurbineTypes(res.data ?? []);
};
useEffect(() => {
fetchTurbineTypes();
}, []);
const handleTurbineTypeSelect = (items: TurbineType[]) => {
setSelected(items[0] ?? null);
};
const handleDeleteButtonClick = async () => {
await deleteTurbineType(selected.id);
fetchTurbineTypes();
};
return (
<div className={styles.page}>
<Heading tag="h1">{route.title}</Heading>
<div className={styles.actions}>
<Link to={ROUTES.turbineType.path.replace(':id', 'new')}>
<Button>Create new</Button>
</Link>
{selected && (
<Link
to={ROUTES.turbineType.path.replace(':id', String(selected.id))}
>
<Button variant="secondary">Edit</Button>
</Link>
)}
{selected && (
<Button variant="secondary" onClick={handleDeleteButtonClick}>
Delete
</Button>
)}
</div>
<div className={styles.dataGridWrapper}>
<DataGrid
items={turbineTypes}
columns={columns}
getItemKey={({ id }) => String(id)}
selectedItems={selected ? [selected] : []}
onItemsSelect={handleTurbineTypeSelect}
multiselect={false}
/>
</div>
</div>
);
}

View File

@ -0,0 +1,8 @@
import { DataGridColumnConfig } from '@components/ui/data-grid/types';
import { TurbineType } from 'src/api/wind';
export const columns: DataGridColumnConfig<TurbineType>[] = [
{ name: 'Name', getText: (t) => t.name, width: 2 },
{ name: 'Height', getText: (t) => String(t.height) },
{ name: 'Blade length', getText: (t) => String(t.bladeLength) },
];

View File

@ -0,0 +1 @@
export * from './component';

View File

@ -0,0 +1,19 @@
.page {
display: grid;
padding: 40px 20px;
gap: 20px;
grid-template-rows: auto auto minmax(0, 1fr);
}
.dataGridWrapper {
overflow: auto;
}
.actions {
display: flex;
padding: 10px;
border-radius: 15px;
background-color: var(--clr-layer-200);
box-shadow: 0px 1px 2px var(--clr-shadow-100);
gap: 10px;
}

View File

@ -1,5 +1,11 @@
import clsx from 'clsx';
import React, { ForwardedRef, forwardRef, useEffect, useState } from 'react';
import React, {
CSSProperties,
ForwardedRef,
forwardRef,
useEffect,
useState,
} from 'react';
import styles from './styles.module.scss';
import { FadeProps } from './types';
@ -14,7 +20,7 @@ export function FadeInner(
}: Omit<FadeProps, 'ref'>,
ref: ForwardedRef<HTMLDivElement>,
) {
const [visibleInner, setVisibleInner] = useState<boolean>(visible);
const [visibleInternal, setVisibleInternal] = useState<boolean>(visible);
const classNames = clsx(
styles.fade,
@ -25,22 +31,21 @@ export function FadeInner(
const inlineStyle = {
...style,
'--animation-duration': `${duration}ms`,
} as React.CSSProperties;
} as CSSProperties;
useEffect(() => {
if (visible) {
setVisibleInner(true);
return;
setVisibleInternal(true);
}
}, [visible]);
const handleAnimationEnd = (event: React.AnimationEvent) => {
if (event.animationName === styles.fadeout) {
setVisibleInner(false);
setVisibleInternal(false);
}
};
if (!visibleInner) {
if (!visibleInternal) {
return null;
}

View File

@ -1,5 +1,5 @@
.fade {
animation: fadein var(--animation-duration);
animation: fadein var(--animation-duration) ease-in-out;
}
.invisible {

View File

@ -1,4 +1,6 @@
import { ComponentProps } from 'react';
export type FadeProps = {
visible: boolean;
duration?: number;
} & React.ComponentProps<'div'>;
} & ComponentProps<'div'>;

View File

@ -1,68 +1,65 @@
import React, {
ForwardedRef,
forwardRef,
useImperativeHandle,
useRef,
useState,
} from 'react';
import clsx from 'clsx';
import React, { useRef } from 'react';
import { RippleWave } from './parts/ripple-wave';
import styles from './styles.module.scss';
import { RippleProps } from './types';
import { calcRippleWaveStyle } from './utils';
export function RippleInner(
props: RippleProps,
ref: ForwardedRef<HTMLDivElement>,
) {
export function Ripple() {
const rippleRef = useRef<HTMLDivElement | null>(null);
const [waves, setWaves] = useState<React.JSX.Element[]>([]);
const [isTouch, setIsTouch] = useState(false);
useImperativeHandle(ref, () => rippleRef.current, []);
const clean = () => {
document.removeEventListener('touchend', clean);
document.removeEventListener('mouseup', clean);
if (!rippleRef.current) {
return;
}
const { lastChild: wave } = rippleRef.current;
if (!wave || !(wave instanceof HTMLElement)) {
return;
}
wave.dataset.isMouseReleased = 'true';
if (wave.dataset.isAnimationComplete) {
wave.classList.replace(styles.visible, styles.invisible);
}
};
const handleWaveOnDone = () => {
setWaves((prev) => prev.slice(1));
const handleAnimationEnd = (event: AnimationEvent) => {
const { target: wave, animationName } = event;
if (!(wave instanceof HTMLElement)) {
return;
}
if (animationName === styles.fadein) {
wave.dataset.isAnimationComplete = 'true';
if (wave.dataset.isMouseReleased) {
wave.classList.replace(styles.visible, styles.invisible);
}
} else {
wave.remove();
}
};
const addWave = (x: number, y: number) => {
const wave = document.createElement('div');
const style = calcRippleWaveStyle(x, y, rippleRef.current);
const wave = (
<RippleWave
key={new Date().getTime()}
style={style}
onDone={handleWaveOnDone}
/>
);
setWaves([...waves, wave]);
Object.assign(wave.style, style);
wave.className = clsx(styles.wave, styles.visible);
wave.addEventListener('animationend', handleAnimationEnd);
rippleRef.current.appendChild(wave);
document.addEventListener('touchend', clean);
document.addEventListener('mouseup', clean);
};
const handleMouseDown = (event: React.MouseEvent) => {
if (isTouch) {
return;
}
const handlePointerDown = (event: React.MouseEvent) => {
event.stopPropagation();
const { pageX, pageY } = event;
addWave(pageX, pageY);
};
const handleTouchStart = (event: React.TouchEvent) => {
setIsTouch(true);
const { touches, changedTouches } = event;
const { pageX, pageY } = touches[0] ?? changedTouches[0];
addWave(pageX, pageY);
};
return (
<div
ref={rippleRef}
className={styles.ripple}
onMouseDown={handleMouseDown}
onTouchStart={handleTouchStart}
{...props}
>
{waves}
</div>
ref={rippleRef}
onPointerDown={handlePointerDown}
/>
);
}
export const Ripple = forwardRef(RippleInner);

View File

@ -1,39 +0,0 @@
import clsx from 'clsx';
import React, { useEffect, useState } from 'react';
import styles from './style.module.scss';
import { RippleWaveProps } from './types';
export function RippleWave({ style, onDone }: RippleWaveProps) {
const [isMouseUp, setIsMouseUp] = useState(false);
const [isAnimationEnd, setIsAnimationEnd] = useState(false);
useEffect(() => {
const mouseUpListener = () => setIsMouseUp(true);
document.addEventListener('mouseup', mouseUpListener, { once: true });
document.addEventListener('touchend', mouseUpListener, { once: true });
}, []);
const visible = !isMouseUp || !isAnimationEnd;
const className = clsx(
styles.wave,
visible ? styles.visible : styles.invisible,
);
const handleAnimationEnd = (event: React.AnimationEvent) => {
if (event.animationName === styles.fadein) {
setIsAnimationEnd(true);
} else {
onDone();
}
};
return (
<div
className={className}
onAnimationEnd={handleAnimationEnd}
style={style}
/>
);
}

View File

@ -1 +0,0 @@
export { RippleWave } from './component';

View File

@ -1,33 +0,0 @@
.wave {
position: absolute;
border-radius: 100%;
background-color: var(--clr-ripple);
}
.visible {
animation: fadein 0.3s linear;
}
.invisible {
animation: fadeout 0.3s linear forwards;
}
@keyframes fadein {
from {
opacity: 0;
scale: 0;
}
to {
opacity: 1;
scale: 1;
}
}
@keyframes fadeout {
from {
opacity: 1;
}
to {
opacity: 0;
}
}

View File

@ -1,6 +0,0 @@
import { CSSProperties } from 'react';
export type RippleWaveProps = {
style: CSSProperties;
onDone: () => void;
};

View File

@ -5,3 +5,38 @@
width: 200%;
height: 200%;
}
.wave {
position: absolute;
border-radius: 100%;
background-color: var(--clr-ripple);
pointer-events: none;
}
.visible {
animation: fadein 0.25s linear;
}
.invisible {
animation: fadeout 0.25s linear;
}
@keyframes fadein {
from {
opacity: 0;
scale: 0;
}
to {
opacity: 1;
scale: 1;
}
}
@keyframes fadeout {
from {
opacity: 1;
}
to {
opacity: 0;
}
}

View File

@ -1 +0,0 @@
export type RippleProps = {} & React.ComponentProps<'div'>;

View File

@ -1,3 +1,4 @@
import { px } from '@utils/css';
import { CSSProperties } from 'react';
export const calcRippleWaveStyle = (
@ -8,7 +9,7 @@ export const calcRippleWaveStyle = (
const wrapperRect = ripple.getBoundingClientRect();
const diameter = Math.max(wrapperRect.width, wrapperRect.height);
const radius = diameter / 2;
const left = x - wrapperRect.left - radius;
const top = y - wrapperRect.top - radius;
return { left, top, width: diameter, height: diameter };
const left = px(x - wrapperRect.left - radius);
const top = px(y - wrapperRect.top - radius);
return { left, top, width: px(diameter), height: px(diameter) };
};

View File

@ -0,0 +1,119 @@
import ArrowDownIcon from '@public/images/svg/arrow-down.svg';
import { useMissClick } from '@utils/miss-click';
import clsx from 'clsx';
import React, {
ForwardedRef,
forwardRef,
useImperativeHandle,
useRef,
useState,
} from 'react';
import { Menu } from '../menu';
import { Popover } from '../popover';
import { TextInput } from '../text-input';
import styles from './styles.module.scss';
import { AutocompleteProps } from './types';
function AutocompleteInner<T>(
{
options,
value,
getOptionKey,
getOptionLabel,
onChange,
scale = 'm',
label = {},
name,
id,
}: Omit<AutocompleteProps<T>, 'ref'>,
ref: ForwardedRef<HTMLDivElement>,
) {
const autocompleteRef = useRef<HTMLDivElement | null>(null);
const menuRef = useRef<HTMLUListElement | null>(null);
const inputWrapperRef = useRef<HTMLDivElement | null>(null);
const [menuVisible, setMenuVisible] = useState<boolean>(false);
const [text, setText] = useState<string>('');
useImperativeHandle(ref, () => autocompleteRef.current, []);
useMissClick({
callback: () => setMenuVisible(false),
enabled: menuVisible,
whitelist: [autocompleteRef, menuRef],
});
const autocompleteClassName = clsx(styles.autocomplete, styles[scale], {
[styles.menuVisible]: menuVisible,
});
const filteredOptions = options.filter((option) => {
const label = getOptionLabel(option).toLocaleLowerCase();
const raw = text.trim().toLocaleLowerCase();
return label.includes(raw);
});
const handleInputClick = () => {
setMenuVisible(!menuVisible);
};
const handleMenuSelect = (option: T) => {
setMenuVisible(false);
onChange?.(option);
setText('');
};
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const { value } = event.target;
setText(value);
const option = options.find((option) => {
const label = getOptionLabel(option).toLocaleLowerCase();
const raw = value.toLocaleLowerCase();
return label === raw;
});
onChange?.(option ?? null);
};
return (
<div className={autocompleteClassName} ref={autocompleteRef}>
<TextInput
value={value ? getOptionLabel(value) : text}
onClick={handleInputClick}
scale={scale}
label={label}
name={name}
id={id}
wrapper={{ ref: inputWrapperRef }}
onChange={handleInputChange}
rightNode={
<div className={styles.iconBox}>
<ArrowDownIcon className={styles.icon} />
</div>
}
/>
<Popover
visible={menuVisible}
anchorRef={autocompleteRef}
position="bottom"
horizontalAlign="stretch"
flip
element={
<div className={styles.menuWrapper}>
<Menu
options={filteredOptions}
selected={value}
getOptionKey={getOptionKey}
getOptionLabel={getOptionLabel}
onSelect={handleMenuSelect}
ref={menuRef}
/>
</div>
}
/>
</div>
);
}
export const Autocomplete = forwardRef(AutocompleteInner) as <T>(
props: AutocompleteProps<T>,
) => ReturnType<typeof AutocompleteInner>;

View File

@ -0,0 +1,3 @@
export { Autocomplete } from './component';
export { AutocompletePreview } from './preview';
export { type AutocompleteProps } from './types';

View File

@ -0,0 +1,44 @@
import { PreviewArticle } from '@components/ui/preview';
import React, { useState } from 'react';
import { Autocomplete } from './component';
export function AutocompletePreview() {
const [selectValue, setSelectValue] = useState<string>();
const options = ['Orange', 'Banana', 'Apple', 'Avocado'];
return (
<PreviewArticle title="Autocomplete">
<Autocomplete
options={options}
getOptionKey={(o) => o}
getOptionLabel={(o) => o}
label={{ text: 'Select your favorite fruit' }}
scale="s"
value={selectValue}
onChange={(o) => setSelectValue(o)}
name="fruit"
/>
<Autocomplete
options={options}
getOptionKey={(o) => o}
getOptionLabel={(o) => o}
label={{ text: 'Select your favorite fruit' }}
scale="m"
value={selectValue}
onChange={(o) => setSelectValue(o)}
name="fruit"
/>
<Autocomplete
options={options}
getOptionKey={(o) => o}
getOptionLabel={(o) => o}
label={{ text: 'Select your favorite fruit' }}
scale="l"
value={selectValue}
onChange={(o) => setSelectValue(o)}
name="fruit"
/>
</PreviewArticle>
);
}

View File

@ -0,0 +1,62 @@
@use '@components/func.scss' as f;
.autocomplete {
position: relative;
width: fit-content;
}
.icon {
fill: var(--clr-text-100);
transition: all var(--td-100) ease-in-out;
}
.fade {
position: absolute;
z-index: 1;
}
.menuVisible {
.icon {
rotate: 180deg;
}
}
.menuWrapper {
padding: 5px 0;
}
$padding-right: 7px;
$size: 10px;
.s {
.iconBox {
padding-right: $padding-right;
}
.icon {
width: $size;
height: $size;
}
}
.m {
.iconBox {
padding-right: f.m($padding-right);
}
.icon {
width: f.m($size);
height: f.m($size);
}
}
.l {
.iconBox {
padding-right: f.l($padding-right);
}
.icon {
width: f.l($size);
height: f.l($size);
}
}

View File

@ -0,0 +1,16 @@
import { ComponentProps, Key } from 'react';
import { LabelProps } from '../label';
import { Scale } from '../types';
export type AutocompleteProps<T> = {
options: T[];
value?: T;
getOptionKey: (option: T) => Key;
getOptionLabel: (option: T) => string;
onChange?: (option: T) => void;
scale?: Scale;
label?: LabelProps;
name?: string;
id?: string;
} & Omit<ComponentProps<'div'>, 'onChange'>;

View File

@ -1,23 +1,25 @@
import clsx from 'clsx';
import React from 'react';
import React, { ForwardedRef, forwardRef } from 'react';
import { Ripple } from '../animation';
import { Ripple } from '../animation/ripple/component';
import { Comet } from '../comet';
import { RawButton } from '../raw';
import { COMET_VARIANT_MAP } from './constants';
import styles from './styles.module.scss';
import { ButtonProps } from './types.js';
export function Button({
function ButtonInner(
{
variant = 'primary',
scale = 'm',
pending = false,
className,
children,
disabled,
...props
}: ButtonProps) {
const classNames = clsx(
}: ButtonProps,
ref: ForwardedRef<HTMLButtonElement>,
) {
const buttonClassName = clsx(
styles.button,
styles[variant],
styles[scale],
@ -25,11 +27,7 @@ export function Button({
className,
);
return (
<RawButton
className={classNames}
disabled={pending ? true : disabled}
{...props}
>
<RawButton className={buttonClassName} ref={ref} {...props}>
{pending && (
<div className={styles.cometWrapper}>
<Comet scale={scale} variant={COMET_VARIANT_MAP[variant]} />
@ -40,3 +38,5 @@ export function Button({
</RawButton>
);
}
export const Button = forwardRef(ButtonInner);

View File

@ -1,14 +1,11 @@
@use '@components/func.scss' as f;
.button {
position: relative;
overflow: hidden;
box-shadow: 0px 2px 2px var(--clr-shadow-200);
font-weight: 500;
transition: all var(--td-100) ease-in-out;
&:disabled {
pointer-events: none;
}
&:not(:disabled) {
cursor: pointer;
}
@ -26,6 +23,8 @@
}
.pending {
pointer-events: none;
.childrenWrapper {
visibility: hidden;
}
@ -35,12 +34,10 @@
background-color: var(--clr-primary);
color: var(--clr-on-primary);
@media (hover: hover) {
&:hover {
background-color: var(--clr-primary-hover);
}
&.pending {
background-color: var(--clr-primary-active);
}
}
@ -48,29 +45,31 @@
background-color: var(--clr-secondary);
color: var(--clr-on-secondary);
@media (hover: hover) {
&:hover {
background-color: var(--clr-secondary-hover);
}
}
}
&.pending {
background-color: var(--clr-secondary-active);
}
}
$padding: 10px 16px;
$border-radius: 8px;
$font-size: 12px;
.s {
padding: 10px 16px;
border-radius: 8px;
font-size: 12px;
padding: $padding;
border-radius: $border-radius;
font-size: $font-size;
}
.m {
padding: 14px 20px;
border-radius: 10px;
font-size: 16px;
padding: f.m($padding);
border-radius: f.m($border-radius);
font-size: f.m($font-size);
}
.l {
padding: 18px 24px;
border-radius: 12px;
font-size: 20px;
padding: f.l($padding);
border-radius: f.l($border-radius);
font-size: f.l($font-size);
}

View File

@ -1,6 +1,6 @@
import React, { ForwardedRef, forwardRef, useEffect, useState } from 'react';
import { CalendarDays } from './parts';
import { CalendarDays } from './components';
import { CalendarProps } from './types';
function CalendarInner(

View File

@ -33,7 +33,7 @@ export function CalendarDays({
}, [date, min, max]);
const handleChange = (newValue: string) => {
onChange?.(newValue);
onChange(newValue);
};
return (

View File

@ -40,21 +40,14 @@
justify-content: center;
border-radius: 10px;
color: var(--clr-text-100);
transition: all var(--td-100) ease-in-out;
&:not(:disabled) {
cursor: pointer;
transition: all var(--td-100) ease-in-out;
&:hover {
background-color: var(--clr-layer-300-hover);
}
}
&:disabled {
color: var(--clr-text-100);
}
}
.currentMonthDay {
color: var(--clr-text-300);
}

View File

@ -9,7 +9,7 @@ export type CalendarDay = {
export type CalendarDaysProps = {
value?: string;
onChange?: (value: string) => void;
onChange: (value: string) => void;
min: Date | null;
max: Date | null;
date: Date;

View File

@ -1,11 +1,18 @@
import { dateToInputString } from '@utils/date';
import { CalendarDay, GetCalendarDaysParams } from './types';
const addDays = (date: Date, days: number) => {
date.setDate(date.getDate() + days);
};
function dateToInputString(date: Date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day}T${hours}:${minutes}`;
}
const daysAreEqual = (date1: Date, date2: Date) => {
return (
date1.getDate() === date2.getDate() &&

View File

@ -1,6 +1,8 @@
import { ComponentProps } from 'react';
export type CalendarProps = {
value?: string;
onChange?: (value: string) => void;
onChange: (value: string) => void;
min: Date | null;
max: Date | null;
} & Omit<React.ComponentProps<'div'>, 'onChange'>;
} & Omit<ComponentProps<'div'>, 'onChange'>;

View File

@ -1,22 +1,26 @@
@use '@components/func.scss' as f;
.checkBoxGroup {
display: flex;
flex-direction: column;
}
$margin-bottom: 4px;
.s {
.label {
margin-bottom: 3px;
margin-bottom: $margin-bottom;
}
}
.m {
.label {
margin-bottom: 5px;
margin-bottom: f.m($margin-bottom);
}
}
.l {
.label {
margin-bottom: 7px;
margin-bottom: f.l($margin-bottom);
}
}

View File

@ -2,7 +2,7 @@ import CheckIcon from '@public/images/svg/check.svg';
import clsx from 'clsx';
import React, { ForwardedRef, forwardRef } from 'react';
import { Ripple } from '../animation';
import { Ripple } from '../animation/ripple/component';
import { Label, LabelProps } from '../label';
import { RawInput } from '../raw';
import styles from './styles.module.scss';

View File

@ -1,8 +1,11 @@
@use '@components/func.scss' as f;
.wrapper {
position: relative;
overflow: hidden;
border-radius: 100%;
cursor: pointer;
user-select: none;
&:hover {
.checkbox {
@ -54,35 +57,40 @@
transition: all var(--td-100) ease-in-out;
}
$padding-outer: 4px;
$size: 16px;
$padding-inner: 2px;
$border-radius: 5px;
.s {
padding: 3px;
padding: $padding-outer;
.checkbox {
width: 16px;
height: 16px;
padding: 2px;
border-radius: 5px;
width: $size;
height: $size;
padding: $padding-inner;
border-radius: $border-radius;
}
}
.m {
padding: 5px;
padding: f.m($padding-outer);
.checkbox {
width: 20px;
height: 20px;
padding: 3px;
border-radius: 6px;
width: f.m($size);
height: f.m($size);
padding: f.m($padding-inner);
border-radius: f.m($border-radius);
}
}
.l {
padding: 7px;
padding: f.l($padding-outer);
.checkbox {
width: 24px;
height: 24px;
padding: 4px;
border-radius: 7px;
width: f.l($size);
height: f.l($size);
padding: f.l($padding-inner);
border-radius: f.l($border-radius);
}
}

View File

@ -1,3 +1,5 @@
@use '@components/func.scss' as f;
.comet {
border-radius: 50%;
animation: spinner-comet 1s infinite linear;
@ -9,23 +11,37 @@
}
}
$size: 12px;
$offset: 1.75px;
.s {
width: 12px;
height: 12px;
mask: radial-gradient(farthest-side, #0000 calc(100% - 2px), #000 0);
width: $size;
height: $size;
mask: radial-gradient(
farthest-side,
#0000 calc(100% - $offset),
#000 0
);
}
.m {
width: 16px;
height: 16px;
mask: radial-gradient(farthest-side, #0000 calc(100% - 2.5px), #000 0);
width: f.m($size);
height: f.m($size);
mask: radial-gradient(
farthest-side,
#0000 calc(100% - f.m($offset)),
#000 0
);
}
.l {
width: 20px;
height: 20px;
mask: radial-gradient(farthest-side, #0000 calc(100% - 3px), #000 0);
}
width: f.l($size);
height: f.l($size);
mask: radial-gradient(
farthest-side,
#0000 calc(100% - f.l($offset)),
#000 0
);}
.onPrimary {
background: conic-gradient(#0000 10%, var(--clr-on-primary));

View File

@ -1,6 +1,8 @@
import { ComponentProps } from 'react';
import { Scale } from '../types';
export type CometProps = {
scale?: Scale;
variant?: 'onPrimary' | 'onSecondary';
} & React.ComponentProps<'div'>;
} & ComponentProps<'div'>;

View File

@ -0,0 +1,77 @@
import React, { useMemo, useState } from 'react';
import { DataGridHeader, DataGridRow } from './components';
import { DataGridProps } from './types';
export function DataGrid<T>({
items,
columns,
getItemKey,
selectedItems,
onItemsSelect,
multiselect,
className,
...props
}: DataGridProps<T>) {
const [allItemsSelected, setAllItemsSelected] = useState<boolean>(false);
const columnsTemplate = useMemo(() => {
const main = columns.map((c) => `${c.width ?? 1}fr`).join(' ');
return `auto ${main}`;
}, []);
const selectedItemsMap = useMemo(() => {
const map: Record<string, T> = {};
for (let i = 0; i < selectedItems.length; i += 1) {
const item = selectedItems[i];
map[String(getItemKey(item))] = item;
}
return map;
}, [selectedItems]);
const handleSelectAllItems = () => {
if (!multiselect) {
return;
}
onItemsSelect?.(allItemsSelected ? [] : [...items]);
setAllItemsSelected(!allItemsSelected);
};
const handleItemSelect = (key: string, item: T) => {
const selected = Boolean(selectedItemsMap[key]);
if (!multiselect) {
onItemsSelect?.(selected ? [] : [item]);
return;
}
onItemsSelect?.(
selected
? selectedItems.filter((i) => key !== getItemKey(i))
: [...selectedItems, item],
);
setAllItemsSelected(false);
};
return (
<div className={className} {...props}>
<DataGridHeader
columns={columns}
allItemsSelected={allItemsSelected}
onSelectAllItems={handleSelectAllItems}
columnsTemplate={columnsTemplate}
/>
{items.map((item) => {
const key = String(getItemKey(item));
return (
<DataGridRow
object={item}
columns={columns}
selected={Boolean(selectedItemsMap[key])}
onSelect={() => handleItemSelect(key, item)}
key={getItemKey(item)}
columnsTemplate={columnsTemplate}
/>
);
})}
</div>
);
}

View File

@ -0,0 +1,65 @@
import { Ripple } from '@components/ui/animation';
import { Checkbox } from '@components/ui/checkbox';
import { RawButton } from '@components/ui/raw';
import { Span } from '@components/ui/span';
import ArrowUpIcon from '@public/images/svg/arrow-up.svg';
import clsx from 'clsx';
import React, { useState } from 'react';
import { DataGridSort } from '../../types';
import styles from './styles.module.scss';
import { DataGridHeaderProps } from './types';
export function DataGridHeader<T>({
columns,
allItemsSelected,
onSelectAllItems,
columnsTemplate,
}: DataGridHeaderProps<T>) {
const [sort, setSort] = useState<DataGridSort>({ order: 'asc', column: '' });
const handleSortButtonClick = (column: string) => {
if (column === sort.column) {
if (sort.order === 'asc') {
setSort({ order: 'desc', column });
} else {
setSort({ order: 'desc', column: '' });
}
} else {
setSort({ order: 'asc', column });
}
};
return (
<header
className={styles.header}
style={{ gridTemplateColumns: columnsTemplate }}
>
<Checkbox
checked={allItemsSelected}
onChange={onSelectAllItems}
label={{ className: styles.checkboxLabel }}
/>
{columns.map((column) => {
const isActive = sort.column === column.name;
const cellClassName = clsx(styles.cell, {
[styles.activeCell]: isActive,
[styles.desc]: isActive && sort.order === 'desc',
});
return (
<RawButton
className={cellClassName}
key={column.name}
onClick={() => handleSortButtonClick(column.name)}
>
<Span color="t300" className={styles.name}>
{column.name}
</Span>
<ArrowUpIcon className={styles.icon} />
<Ripple />
</RawButton>
);
})}
</header>
);
}

View File

@ -0,0 +1 @@
export * from './component';

View File

@ -0,0 +1,59 @@
.header {
display: grid;
}
.checkboxLabel {
padding: 10px;
border: solid 1px var(--clr-border-100);
background-color: var(--clr-layer-300);
border-top-left-radius: 10px;
}
.cell {
position: relative;
display: flex;
overflow: hidden;
align-items: center;
padding: 10px;
border: solid 1px var(--clr-border-100);
background-color: var(--clr-layer-300);
cursor: pointer;
gap: 10px;
transition: all var(--td-100) ease-in-out;
&:last-of-type {
border-top-right-radius: 10px;
}
@media (hover: hover) {
&:hover {
background-color: var(--clr-layer-300-hover);
}
}
}
.name {
overflow: hidden;
flex-shrink: 1;
text-overflow: ellipsis;
}
.icon {
width: 12px;
height: 12px;
flex-shrink: 0;
fill: transparent;
transition: all var(--td-100) ease-in-out;
}
.activeCell {
.icon {
fill: var(--clr-text-200);
}
}
.desc {
.icon {
rotate: 180deg;
}
}

View File

@ -0,0 +1,8 @@
import { DataGridColumnConfig } from '../../types';
export type DataGridHeaderProps<T> = {
columns: DataGridColumnConfig<T>[];
allItemsSelected: boolean;
onSelectAllItems: () => void;
columnsTemplate: string;
};

View File

@ -0,0 +1,32 @@
import { Checkbox } from '@components/ui/checkbox';
import { Span } from '@components/ui/span';
import React from 'react';
import styles from './styles.module.scss';
import { DataGridRowProps } from './types';
export function DataGridRow<T>({
object,
columns,
selected,
onSelect,
columnsTemplate,
}: DataGridRowProps<T>) {
return (
<div
className={styles.row}
style={{ gridTemplateColumns: columnsTemplate }}
>
<Checkbox
checked={selected}
label={{ className: styles.checkboxLabel }}
onChange={onSelect}
/>
{columns.map((column) => (
<div className={styles.cell} key={column.name}>
<Span>{column.getText(object)}</Span>
</div>
))}
</div>
);
}

View File

@ -0,0 +1 @@
export * from './component';

View File

@ -0,0 +1,19 @@
.row {
display: grid;
}
.checkboxLabel {
padding: 10px;
border: solid 1px var(--clr-border-100);
background-color: var(--clr-layer-200);
}
.cell {
display: flex;
overflow: hidden;
align-items: center;
padding: 10px;
border: solid 1px var(--clr-border-100);
background-color: var(--clr-layer-200);
overflow-wrap: anywhere;
}

View File

@ -0,0 +1,9 @@
import { DataGridColumnConfig } from '../../types';
export type DataGridRowProps<T> = {
object: T;
columns: DataGridColumnConfig<T>[];
selected: boolean;
onSelect: () => void;
columnsTemplate: string;
};

Some files were not shown because too many files have changed in this diff Show More