feat: add text api

This commit is contained in:
Pavel Torbeev 2023-09-09 15:14:45 +03:00
parent b2285e263b
commit 5b47207bfd
19 changed files with 497 additions and 178 deletions

View File

@ -0,0 +1,34 @@
import { useQuery } from '@tanstack/react-query';
import { axios } from '../../lib/axios';
import { ExtractFnReturnType, QueryConfig } from '../../lib/react-query';
import { ProcessDescriptor, ScoreType, TextDescriptor } from './types';
import { PROCESS_API_URL, TEXT_API_URL, TEXT_PARAM } from './urlKeys';
import { QUERY_KEY_PROCESSES, QUERY_KEY_TEXTS } from './queryKeys';
export type GetTextResponse = TextDescriptor;
export const getText = ({ textId, type = 'bert' }: { textId: number; type: ScoreType }): Promise<GetTextResponse> => {
return axios.get(TEXT_API_URL.replace(`:${TEXT_PARAM}`, String(textId)), {
params: {
type
}
});
};
type QueryFnType = typeof getText;
type UseTextOptions = {
textId: number;
type: ScoreType;
config?: QueryConfig<QueryFnType>;
};
export const useText = ({ textId, type, config }: UseTextOptions) => {
return useQuery<ExtractFnReturnType<QueryFnType>>({
...config,
queryKey: [QUERY_KEY_TEXTS, textId, type],
queryFn: async () => {
return await getText({ textId, type });
}
});
};

View File

@ -1,2 +1,4 @@
export * from './types';
export * from './createProcess';
export * from './getProcess';
export * from './getText';

View File

@ -1 +1,2 @@
export const QUERY_KEY_PROCESSES = 'processes';
export const QUERY_KEY_TEXTS = 'texts';

View File

@ -1,6 +1,27 @@
export type TextDescriptor = {
score: string;
export type ScoreType = 'bert' | 'f';
export type ScoreDescriptor = {
[key in ScoreType]: {
text: string;
answer: string;
};
};
export type TextDescriptor = {
id: number;
file_name: string;
description:
| {
[key in ScoreType]?: {
file?: string;
pdf?: string;
text: string;
};
}
| null;
text: string;
summary: string;
score: ScoreDescriptor;
};
export type ProcessDescriptor = {

View File

@ -1,2 +1,5 @@
export const PROCESS_API_URL = '/process';
export const PROCESS_PARAM = 'processId';
export const TEXT_PARAM = 'textId';
export const TEXT_API_URL = `/process/describe/:${TEXT_PARAM}`;

View File

@ -1,5 +1,6 @@
import { TEXT_PAGE_PARAM, TEXT_PAGE_ROUTE } from './routes';
import { RESPONSE_PAGE_PARAM, RESPONSE_PAGE_ROUTE, TEXT_PAGE_PARAM, TEXT_PAGE_ROUTE } from './routes';
export class PathBuilder {
static getProcessPath = (id: string) => RESPONSE_PAGE_ROUTE.replace(`:${RESPONSE_PAGE_PARAM}`, String(id));
static getTextPath = (id: number) => TEXT_PAGE_ROUTE.replace(`:${TEXT_PAGE_PARAM}`, String(id));
}

View File

@ -44,6 +44,10 @@
&:active {
color: $color-brand-active;
}
&.Link_disabled {
color: $color-brand-disabled;
}
}
.Link_underlined {

View File

@ -7,5 +7,6 @@
@include media-down(tablet-small) {
border-radius: 0 0 $radius-small $radius-small;
@include text-body-s-regular;
}
}

View File

@ -3,5 +3,6 @@
// export const BACKEND_URL = 'https://ed68-77-234-219-9.ngrok-free.app';
// export const BACKEND_URL = 'https://16c2-77-234-219-9.ngrok-free.app';
export const BACKEND_URL = 'http://192.168.107.4';
export const BACKEND_MEDIA_PORT = '8000';
export const API_URL = BACKEND_URL + '/api';

View File

@ -81,6 +81,10 @@ $textarea-height: 192px;
min-height: $textarea-height;
}
.HomePage__textareaInput {
min-height: 160px;
}
.HomePage__filesContainer {
@include flex-middle;
flex-wrap: wrap;

View File

@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from 'react';
import { SubmitHandler, useForm } from 'react-hook-form';
import { useDropzone } from 'react-dropzone';
import { useNavigate } from 'react-router-dom';
import clsx from 'clsx';
import { ReactFCC } from '../../utils/ReactFCC';
import { Heading, HeadingSize } from '../../components/Heading';
@ -14,6 +15,7 @@ import { useSingleTimeout } from '../../hooks/useSingleTimeout';
import { Upload } from '../../components/Upload';
import { Attachment } from '../../components/Attachment';
import { Loader } from '../../components/Loader';
import { PathBuilder } from '../../app/routes';
import { ReactComponent as PlusIcon } from './assets/plus.svg';
import s from './HomePage.module.scss';
@ -61,12 +63,10 @@ export const HomePage: ReactFCC = () => {
const onSubmit: SubmitHandler<FormFields> = useCallback(
async (data) => {
const response = await createProcess({
await createProcess({
text: data.text,
files: data.files
});
// setProcessId(response.id);
},
[createProcess]
);
@ -88,6 +88,14 @@ export const HomePage: ReactFCC = () => {
}
}, [processId, refetchProcess, timeout]);
const navigate = useNavigate();
useEffect(() => {
if (processId && process && process.current === process.total) {
navigate(PathBuilder.getProcessPath(processId));
}
}, [navigate, process, processId]);
// ------ Обработка DnD ------
const currentText = watch('text');
@ -141,6 +149,9 @@ export const HomePage: ReactFCC = () => {
{currentFiles.length === 0 ? (
<Textarea
className={s.HomePage__textarea}
classes={{
input: s.HomePage__textareaInput
}}
registration={register('text')}
rows={8}
placeholder={'Текст пресс-релиза...'}

View File

@ -1,7 +1,7 @@
@import 'src/app/styles/vars';
.ResponsePage {
padding-bottom: $spacing-medium-x;
}
.ResponsePage__title {
@ -13,7 +13,7 @@
@include mobile-down {
@include flex-col;
row-gap: $spacing-small-2x;
row-gap: $spacing-small-x;
}
}
@ -63,11 +63,11 @@
@include transition(border-color, color);
&.ResponsePage__tableSummary {
@include transition(opacity);
opacity: 0;
//@include transition(opacity);
//opacity: 0;
&:hover {
opacity: 1;
//opacity: 1;
}
}
@ -76,3 +76,11 @@
}
}
}
.ResponsePage__modalBody {
line-height: $line-height-24;
@include mobile-down {
//line-height: $line-height-20;
}
}

View File

@ -1,21 +1,47 @@
import { FC, useCallback, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Heading, HeadingSize } from '../../components/Heading';
import { Link } from '../../components/Link';
import { getPercentageColor } from '../../utils/getPercentageColor';
import { EMDASH } from '../../utils/chars';
import { ModalBody, useModal, ModalContainer } from '../../components/Modal';
import { useIsMobile } from '../../hooks/useIsMobile';
import { useUrlParam } from '../../hooks/useUrlParam';
import { PathBuilder, RESPONSE_PAGE_PARAM } from '../../app/routes';
import { useProcess } from '../../api/process/getProcess';
import { TextItem } from './components';
import s from './ResponsePage.module.scss';
export const ResponsePage: FC = () => {
const processId = useUrlParam(RESPONSE_PAGE_PARAM);
const isMobile = useIsMobile();
const navigate = useNavigate();
// ------ Работа с данными ------
const { data } = useProcess({
processId: processId || '',
config: {
enabled: !!processId
}
});
const texts = data?.texts || [];
// ------ Обработка модалки с саммари ------
const [isOpen, setIsOpen] = useState(false);
const onClose = useCallback(() => {
setIsOpen(false);
}, []);
const { modalIsVisible, isClosing, close } = useModal({ isOpen, onClose });
const isMobile = useIsMobile();
const [summaryText, setSummaryText] = useState('');
const openSummary = useCallback((text: string = '') => {
setSummaryText(text);
setIsOpen(true);
}, []);
return (
<div className={s.ResponsePage}>
@ -26,8 +52,14 @@ export const ResponsePage: FC = () => {
<div className={s.ResponsePage__container}>
{isMobile ? (
<>
<TextItem onClickSummary={() => setIsOpen(true)} />
<TextItem />
{texts.map((text, index) => (
<TextItem
text={text}
onClick={() => navigate(PathBuilder.getTextPath(text.id))}
onClickSummary={() => openSummary(text.summary)}
key={index}
/>
))}
</>
) : (
<table className={s.ResponsePage__table}>
@ -37,71 +69,79 @@ export const ResponsePage: FC = () => {
<th>Имя</th>
<th>М. н-с.</th>
<th>М. стат.</th>
<th>М. п.</th>
<th>Рез.</th>
{/*<th>М. п.</th>*/}
{/*<th>Рез.</th>*/}
<th>Крат. сод.</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td>file.txt</td>
{texts.map((text, index) => (
<tr onClick={() => navigate(PathBuilder.getTextPath(text.id))} key={index}>
<td>{text.id}</td>
<td>{text.file_name.length > 10 ? `${text.file_name.slice(0, 10)}...` : text.file_name}</td>
<td>
AA+ | <span style={{ color: getPercentageColor(0.63) }}>0.63</span>
{text.score.bert.answer}
{/*| <span style={{ color: getPercentageColor(0.99) }}>0.99</span>*/}
</td>
<td>
AA+ | <span style={{ color: getPercentageColor(0.95) }}>0.95</span>
</td>
<td>
AA+ | <span style={{ color: getPercentageColor(0.95) }}>0.95</span>
</td>
<td>
AA+ | <span style={{ color: getPercentageColor(0.95) }}>0.95</span>
</td>
<td className={s.ResponsePage__tableSummary}>
<Link standalone={false}>Открыть</Link>
</td>
</tr>
<tr>
<td>1</td>
<td>{EMDASH}</td>
<td>
AA+ | <span style={{ color: getPercentageColor(0.63) }}>0.63</span>
</td>
<td>
AA+ | <span style={{ color: getPercentageColor(0.95) }}>0.95</span>
</td>
<td>
AA+ | <span style={{ color: getPercentageColor(0.95) }}>0.95</span>
</td>
<td>
AA+ | <span style={{ color: getPercentageColor(0.95) }}>0.95</span>
{text.score.f.answer}
{/*| <span style={{ color: getPercentageColor(0.99) }}>0.99</span>*/}
</td>
{/*<td>*/}
{/* AA+ | <span style={{ color: getPercentageColor(0.95) }}>0.95</span>*/}
{/*</td>*/}
{/*<td>*/}
{/* AA+ | <span style={{ color: getPercentageColor(0.95) }}>0.95</span>*/}
{/*</td>*/}
<td className={s.ResponsePage__tableSummary}>
<Link
component={'button'}
standalone={false}
onClick={(e) => {
e.stopPropagation();
setIsOpen(true);
openSummary(text.summary);
}}>
Открыть
</Link>
</td>
</tr>
))}
{/*<tr>*/}
{/* <td>1</td>*/}
{/* <td>{EMDASH}</td>*/}
{/* <td>*/}
{/* AA+ | <span style={{ color: getPercentageColor(0.63) }}>0.63</span>*/}
{/* </td>*/}
{/* <td>*/}
{/* AA+ | <span style={{ color: getPercentageColor(0.95) }}>0.95</span>*/}
{/* </td>*/}
{/* <td>*/}
{/* AA+ | <span style={{ color: getPercentageColor(0.95) }}>0.95</span>*/}
{/* </td>*/}
{/* <td>*/}
{/* AA+ | <span style={{ color: getPercentageColor(0.95) }}>0.95</span>*/}
{/* </td>*/}
{/* <td className={s.ResponsePage__tableSummary}>*/}
{/* <Link*/}
{/* component={'button'}*/}
{/* standalone={false}*/}
{/* onClick={(e) => {*/}
{/* e.stopPropagation();*/}
{/* setIsOpen(true);*/}
{/* }}>*/}
{/* Открыть*/}
{/* </Link>*/}
{/* </td>*/}
{/*</tr>*/}
</tbody>
</table>
)}
</div>
<ModalContainer isOpen={modalIsVisible} onClose={close} isClosing={isClosing}>
<ModalBody>
Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industrys
standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to
make a type specimen book.
</ModalBody>
<ModalBody className={s.ResponsePage__modalBody}>{summaryText}</ModalBody>
</ModalContainer>
</div>
);

View File

@ -1,9 +1,16 @@
@import 'src/app/styles/vars';
.TextItem {
@include transition(background-color);
background-color: $color-background-dark-100;
border-radius: $radius-medium;
padding: $spacing-small-2x;
cursor: pointer;
&:hover {
background-color: $color-background-dark-200;
}
}
.TextItem__row {
@ -18,13 +25,15 @@
.TextItem__row_name {
margin-bottom: $spacing-small-3x;
overflow: hidden;
text-overflow: ellipsis;
}
.TextItem__name {
//font-size: $font-size-18;
text-transform: uppercase;
font-weight: $font-weight-600;
white-space: nowrap;
}
.TextItem__row_link {

View File

@ -3,6 +3,7 @@ import { ReactFCC } from '../../../../utils/ReactFCC';
import { ETextVariants, Text } from '../../../../components/Text';
import { getPercentageColor } from '../../../../utils/getPercentageColor';
import { Link } from '../../../../components/Link';
import { TextDescriptor } from '../../../../api/process';
import s from './TextItem.module.scss';
export interface TextItemProps {
@ -10,49 +11,51 @@ export interface TextItemProps {
* Дополнительный css-класс
*/
className?: string;
text: TextDescriptor;
onClick?: () => void;
onClickSummary?: () => void;
}
export const TextItem: ReactFCC<TextItemProps> = (props) => {
const { className, onClickSummary } = props;
const { className, text, onClick, onClickSummary } = props;
return (
<div className={clsx(s.TextItem, className)}>
<div className={clsx(s.TextItem, className)} onClick={onClick}>
<div className={clsx(s.TextItem__row, s.TextItem__row_name)}>
{/*<Text component={'span'} variant={ETextVariants.BODY_S_MEDIUM}>*/}
{/* Имя:*/}
{/*</Text>{' '}*/}
<Text className={s.TextItem__name} component={'span'} variant={ETextVariants.PROGRAMMING_CODE_REGULAR}>
file.txt #1
#{text.id} | {text.file_name}
</Text>
{/*{' | '}*/}
{/*<Text component={'span'} variant={ETextVariants.BODY_S_REGULAR}>*/}
{/* file.txt*/}
{/*</Text>*/}
</div>
<div className={s.TextItem__row}>
<Text component={'span'} variant={ETextVariants.PROGRAMMING_CODE_REGULAR}>
М. н-с: AA+ | <span style={{ color: getPercentageColor(0.63) }}>0.63</span>
М. н-с: {text.score.bert.answer}
{/*| <span style={{ color: getPercentageColor(0.99) }}>0.99</span>*/}
</Text>{' '}
</div>
<div className={s.TextItem__row}>
<Text component={'span'} variant={ETextVariants.PROGRAMMING_CODE_REGULAR}>
М. стат: AA+ | <span style={{ color: getPercentageColor(0.63) }}>0.63</span>
М. стат: {text.score.f.answer}
{/*| <span style={{ color: getPercentageColor(0.99) }}>0.99</span>*/}
</Text>{' '}
</div>
<div className={s.TextItem__row}>
<Text component={'span'} variant={ETextVariants.PROGRAMMING_CODE_REGULAR}>
М. п: AA+ | <span style={{ color: getPercentageColor(0.63) }}>0.63</span>
</Text>{' '}
</div>
{/*<div className={s.TextItem__row}>*/}
{/* <Text component={'span'} variant={ETextVariants.PROGRAMMING_CODE_REGULAR}>*/}
{/* М. п: AA+ | <span style={{ color: getPercentageColor(0.63) }}>0.63</span>*/}
{/* </Text>{' '}*/}
{/*</div>*/}
<div className={clsx(s.TextItem__row, s.TextItem__row_link)}>
<Text component={'span'} variant={ETextVariants.BODY_S_REGULAR}>
<Link component={'button'} standalone={false} onClick={() => onClickSummary?.()}>
<Link
component={'button'}
standalone={false}
onClick={(e) => {
e.stopPropagation();
onClickSummary?.();
}}>
Краткое содержание
</Link>
</Text>

View File

@ -1,7 +1,13 @@
@import 'src/app/styles/vars';
.TextPage {
@include flex-col;
padding-bottom: $spacing-medium-x;
height: 100%;
}
.TextPage__container {
width: 100%;
}
.TextPage__title {
@ -57,6 +63,7 @@
}
.TextPage__fullText {
@include text-body-m-regular;
line-height: $line-height-24;
@include mobile-up {
@ -78,3 +85,51 @@
padding: 0 2px;
margin: 0 -2px;
}
.TextPage__selectLabel {
margin-bottom: $spacing-small-4x;
}
.TextPage__select {
@include reset-default-input;
padding: 0 ($spacing-small-2x - 1px);
color: $color-text-primary;
border: 1px solid $color-border-default;
border-radius: $radius-medium-x;
box-sizing: border-box;
height: 32px;
min-width: 200px;
margin-bottom: $spacing-small-x;
@include mobile-down {
width: 100%;
}
}
.TextPage__textTip {
@include transition(opacity);
@include text-body-s-regular;
pointer-events: none;
position: fixed;
top: 0;
left: 0;
visibility: hidden;
opacity: 0;
padding: 8px 12px;
border-radius: $radius-medium;
background-color: rgba($color-text-primary, 0.8);
color: $color-background-primary;
}
.TextPage__loaderContainer {
@include flex-col-middle;
justify-content: center;
row-gap: $spacing-small-4x;
height: 100%;
}
.TextPage__loader {
--loader-size: 96px;
color: $color-background-secondary !important;
}

View File

@ -1,4 +1,5 @@
import { FC } from 'react';
import { FC, useEffect, useMemo, useRef } from 'react';
import { useForm } from 'react-hook-form';
import { Heading, HeadingSize } from '../../components/Heading';
import { useUrlParam } from '../../hooks/useUrlParam';
import { TEXT_PAGE_PARAM } from '../../app/routes';
@ -6,56 +7,163 @@ import { ETextVariants, Text } from '../../components/Text';
import { getPercentageColor } from '../../utils/getPercentageColor';
import { Tooltip } from '../../components/Tooltip';
import { Link } from '../../components/Link';
import { useSingleTimeout } from '../../hooks/useSingleTimeout';
import { ScoreType, useText } from '../../api/process';
import { Loader } from '../../components/Loader';
import { BACKEND_MEDIA_PORT, BACKEND_URL } from '../../config';
import { getEntriesFromText } from './utils/getEntriesFromText';
import s from './TextPage.module.scss';
export type TextFields = {
type: ScoreType;
};
export const TEXT_REFETCH_MS = 2000;
export const TextPage: FC = () => {
const textId = useUrlParam(TEXT_PAGE_PARAM, { parser: parseInt });
// ------ Работа с данными ------
const { register, watch } = useForm<TextFields>({
defaultValues: {
type: 'bert'
}
});
const scoreType = watch('type');
const {
data: textEntity,
isLoading,
error
} = useText({
textId: textId || 0,
type: scoreType,
config: {
enabled: !!textId,
refetchInterval: (data) =>
data?.description?.[scoreType]?.file && data?.description?.[scoreType]?.pdf ? false : TEXT_REFETCH_MS
}
});
const parsedText = useMemo(
() => getEntriesFromText(textEntity?.description?.[scoreType]?.text || ''),
[scoreType, textEntity]
);
const docxHref = textEntity?.description?.[scoreType]?.file
? `${BACKEND_URL}:${BACKEND_MEDIA_PORT}${textEntity?.description?.[scoreType]?.file}`
: undefined;
const pdfHref = textEntity?.description?.[scoreType]?.pdf
? `${BACKEND_URL}:${BACKEND_MEDIA_PORT}${textEntity?.description?.[scoreType]?.pdf}`
: undefined;
// ------ Обработка UI ------
const textRef = useRef<HTMLDivElement>(null);
const tipRef = useRef<HTMLDivElement>(null);
const timeout = useSingleTimeout();
useEffect(() => {
if (!textRef.current) {
return;
}
const resetTip = () => {
if (tipRef.current) {
tipRef.current.style.opacity = `0`;
timeout.set(() => {
if (tipRef.current) {
tipRef.current.style.top = `0px`;
tipRef.current.style.left = `0px`;
tipRef.current.style.visibility = `hidden`;
tipRef.current.innerText = ``;
}
}, 500);
}
};
textRef.current.querySelectorAll('span').forEach((item) => {
item.addEventListener('mouseover', () => {
const rect = item.getBoundingClientRect();
const value = Number(item.getAttribute('data-value'));
if (tipRef.current && value > 0.1) {
timeout.clear();
tipRef.current.style.top = `${rect.y - 24}px`;
tipRef.current.style.left = `${rect.x + 8}px`;
tipRef.current.style.visibility = `visible`;
tipRef.current.style.opacity = `1`;
tipRef.current.innerText = `Точность ${value.toFixed(2)}`;
}
});
item.addEventListener('mouseout', resetTip);
});
window.addEventListener('scroll', resetTip);
window.addEventListener('touchmove', resetTip);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [parsedText]);
// ------ Обработка ошибки ------
if (error) {
return null;
}
return (
<div className={s.TextPage}>
{textEntity && !isLoading ? (
<div className={s.TextPage__container}>
<Heading size={HeadingSize.H2} className={s.TextPage__title}>
Результат обработки запроса {textId}
Результат обработки запроса {textEntity.id}
</Heading>
<div className={s.TextPage__props}>
<Text className={s.TextPage__prop} variant={ETextVariants.PROGRAMMING_CODE_REGULAR}>
Имя файла: file.txt
Имя файла: {textEntity.file_name}
</Text>
<Text component={'div'} className={s.TextPage__prop} variant={ETextVariants.PROGRAMMING_CODE_REGULAR}>
Результат по{' '}
<Tooltip className={s.TextPage__tooltip} content={'Языковая модель (Berd)'}>
<Tooltip className={s.TextPage__tooltip} content={'Языковая модель (Bert)'} placement={'right'}>
<span className={s.TextPage__underline}>нейросетевому</span>
</Tooltip>{' '}
методу: АА+ | Accuracy: <span style={{ color: getPercentageColor(0.95) }}>0.95</span>
методу: {textEntity.score.bert.answer}
{/*| Accuracy: <span style={{ color: getPercentageColor(0.95) }}>0.95</span>*/}
</Text>
<Text component={'div'} className={s.TextPage__prop} variant={ETextVariants.PROGRAMMING_CODE_REGULAR}>
Результат по{' '}
<Tooltip className={s.TextPage__tooltip} content={'Лемматизация + TF/IDF + RandomForest'}>
<Tooltip
className={s.TextPage__tooltip}
content={'Лемматизация + TF/IDF + RandomForest'}
placement={'right'}>
<span className={s.TextPage__underline}>статистическому</span>
</Tooltip>{' '}
методу: АА+ | Accuracy: <span style={{ color: getPercentageColor(0.71) }}>0.71</span>
методу: {textEntity.score.f.answer}
{/*| Accuracy: <span style={{ color: getPercentageColor(0.71) }}>0.71</span>*/}
</Text>
<Text component={'div'} className={s.TextPage__prop} variant={ETextVariants.PROGRAMMING_CODE_REGULAR}>
Результат по методу{' '}
<Tooltip className={s.TextPage__tooltip} content={'Berd + Annoy'}>
<span className={s.TextPage__underline}>похожести</span>
</Tooltip>
: АА+ | Accuracy: <span style={{ color: getPercentageColor(0.63) }}>0.63</span>
</Text>
{/*<Text component={'div'} className={s.TextPage__prop} variant={ETextVariants.PROGRAMMING_CODE_REGULAR}>*/}
{/* Результат по методу{' '}*/}
{/* <Tooltip className={s.TextPage__tooltip} content={'Bert + Annoy'}>*/}
{/* <span className={s.TextPage__underline}>похожести</span>*/}
{/* </Tooltip>*/}
{/* : АА+ | Accuracy: <span style={{ color: getPercentageColor(0.63) }}>0.63</span>*/}
{/*</Text>*/}
</div>
<div className={s.TextPage__summary}>
<Heading size={HeadingSize.H4} className={s.TextPage__summaryHeading}>
Summary
Краткое содержание
</Heading>
<Text className={s.TextPage__summaryText} variant={ETextVariants.BODY_M_REGULAR}>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore
magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo
consequat.
{textEntity.summary}
</Text>
</div>
@ -64,56 +172,44 @@ export const TextPage: FC = () => {
Полный текст
</Heading>
<Link className={s.TextPage__summaryLink}>Скачать DOCX</Link>
<Link className={s.TextPage__summaryLink}>Скачать PDF</Link>
<Text className={s.TextPage__fullText} variant={ETextVariants.BODY_M_REGULAR}>
Повышение кредитного рейтинга Акционерного общества «Уральская сталь» (далее «Уральская сталь», Компания)
вызвано улучшением качественной оценки ликвидности в связи с рефинансированием краткосрочного банковского
кредита посредством выпуска облигационного займа с погашением в 2025 году. Также{' '}
<span className={s.TextPage__tag}>пересмотр стратегических планов</span> по реализации ряда инвестиционных
проектов способствовал улучшению показателя «капитальные затраты к выручке». Улучшение ценовой конъюнктуры на
мировом рынке чугуна обеспечило запуск доменной печи 3, находившейся ранее в резерве, что окажет
дополнительное положительное влияние на денежный поток Компании в 2023 году. Кредитный рейтинг Компании
определяется средними рыночной позицией, бизнес-профилем и уровнем корпоративного управления, а также средней
оценкой за размер бизнеса. Показатели рентабельности, ликвидности, долговой нагрузки, обслуживания долга и
денежного потока получили высокие оценки. «Уральская сталь» один из крупнейших в России производителей
товарного чугуна, мостостали и стали для производства труб большого диаметра (ТБД). В начале 2022 года
Акционерное общество «Загорский трубный завод» ( рейтинг АКРА rating, прогноз «Стабильный» ; далее ЗТЗ)
<span className={s.TextPage__tag}>приобрело 100% уставного капитала</span> Компании у АО «ХК «МЕТАЛЛОИНВЕСТ» (
рейтинг АКРА rating, прогноз «Стабильный» ). Ключевые факторы оценки Средняя оценка рыночной позиции
обусловлена оценкой рыночных позиций «Уральской стали» по основным видам продукции (мостосталь, штрипс и
чугун), взвешенных с учетом их доли в консолидированной выручке Компании. Средняя оценка бизнес-профиля
Компании определяется: низкой оценкой степени вертикальной интеграции, которая отсутствует в Компании,
поскольку она не обеспечена собственными углем и железорудным сырьем; средней оценкой за долю продукции с
высокой добавленной стоимостью, которая учитывает сталь для ТБД и мостосталь как высокотехнологичные виды
продукции; средней оценкой за характеристику и диверсификацию рынков сбыта, так как рынки сбыта основной
продукции «Уральской стали» характеризуются умеренной цикличностью и насыщенностью, а продуктовый портфель
Компании умеренно диверсифицирован. Средняя оценка географической диверсификации является следствием наличия
экспорта чугуна, толстолистового проката и заготовки, доля которого формирует до{' '}
<span className={s.TextPage__tag}>50% консолидированной выручки</span>
Компании. С одной стороны, это обуславливает высокую оценку субфактора «доступность и диверсификация рынков
сбыта», а с другой очень низкую оценку субфактора «концентрация на одном заводе». Средний уровень
корпоративного управления обусловлен прозрачной структурой бизнеса и успешной реализацией Компанией стратегии
роста и расширения продуктового портфеля. Топ-менеджмент Компании представлен экспертами с большим опытом
работы в отрасли. «Уральская сталь» применяет отдельные элементы системы риск-менеджмента (например,
хеджирование валютного риска в определенных случаях), однако единые документы по стратегии и управлению
рисками, а также по дивидендной политике пока не утверждены. Совет директоров и ключевые комитеты пока не
сформированы. Структура бизнеса проста. Компания готовит отчетность по МСФО. Высокая оценка финансового
риск-профиля Компании обусловлена: высокой оценкой за рентабельность (рентабельность по FFO до процентов и
налогов за 2022 год составила 12% и ожидается АКРА на уровне{' '}
<span className={s.TextPage__tag}>около 18%</span> в 2023-м); высокой оценкой за обслуживание долга (отношение
FFO до чистых процентных платежей к процентным платежам составило 24,7х по результатам 2022 года и
прогнозируется АКРА на уровне около 11,7х в 2023-м); высокой оценкой за долговую нагрузку (отношение общего
долга с учетом поручительства по долгу ЗТЗ к FFO до чистых процентных платежей ожидается АКРА на уровне 2,5х
(0,8х без учета поручительств) по результатам 2023 года); средней оценкой размера бизнеса (абсолютное значение
годового FFO до чистых процентных платежей и налогов менее 30 млрд руб.). Высокая оценка уровня ликвидности.
<Text className={s.TextPage__selectLabel} variant={ETextVariants.CAPTION_M_REGULAR}>
Метод
</Text>
</div>
<select className={s.TextPage__select} {...register('type')}>
<option value="bert">Нейросетевой</option>
<option value="f">Статистический</option>
{/*<option value="f">Схожести</option>*/}
</select>
{/*<div className={s.TextPage__downloads}>*/}
{/* <a href="#">Скачать DOCX</a>*/}
{/*</div>*/}
<Link
component={'a'}
className={s.TextPage__summaryLink}
href={docxHref}
target={'_blank'}
// download={textEntity.file_name}
disabled={!docxHref}>
Скачать DOCX
</Link>
<Link
component={'a'}
className={s.TextPage__summaryLink}
href={pdfHref}
target={'_blank'}
// download={textEntity.file_name}
disabled={!pdfHref}>
Скачать PDF
</Link>
<div className={s.TextPage__fullText} dangerouslySetInnerHTML={{ __html: parsedText }} ref={textRef} />
<div className={s.TextPage__textTip} ref={tipRef} />
</div>
</div>
) : (
<div className={s.TextPage__loaderContainer}>
<Loader className={s.TextPage__loader} />
</div>
)}
</div>
);
};

2
src/pages/text/text.ts Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,23 @@
export const getColorFromValue = (value: number) => {
return `rgba(255, 255, 0, ${value > 0.1 ? value + 0.2 : 0})`;
};
export const getEntriesFromText = (html: string) => {
let copiedHtml = html;
const matches = Array.from(html.matchAll(/<span[^>]+>(.*?)<\/span>/gi));
matches.forEach((match) => {
const entry = match[0];
const text = match[1];
const value = Number(Array.from(entry.matchAll(/data-value=\\?"?([.\d]+)\\?"?/gi))[0]?.[1]) || 0;
copiedHtml = copiedHtml.replace(
match[0],
`<span data-value="${value}" style="background-color: ${getColorFromValue(
value
)}; /*padding: 0 2px; margin: 0 -2px;*/">${text}</span>`
);
});
return copiedHtml;
};